Compare commits
37 Commits
austin/nod
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6c44aabf | ||
|
|
2cdaead000 | ||
|
|
003303d8ac | ||
|
|
ca2ead3c4a | ||
|
|
1a27372e44 | ||
|
|
443e201684 | ||
|
|
cf7c68cd50 | ||
|
|
0a4021df99 | ||
|
|
eafe2af91d | ||
|
|
4c870d84ed | ||
|
|
4f60d09420 | ||
|
|
edf3e1a682 | ||
|
|
968598d9e6 | ||
|
|
305d209f6f | ||
|
|
0c23e8305f | ||
|
|
810ada61fb | ||
|
|
ec91ecd695 | ||
|
|
c408f39cee | ||
|
|
ccf2f12b25 | ||
|
|
6850d22d99 | ||
|
|
543a39a6b0 | ||
|
|
c5eb05a2e9 | ||
|
|
5a846db6cf | ||
|
|
e994e4df58 | ||
|
|
2a7340ec6c | ||
|
|
cb52a3821b | ||
|
|
7a877d0715 | ||
|
|
ac4105cca8 | ||
|
|
36b57f1e83 | ||
|
|
8c04f3261a | ||
|
|
941f220582 | ||
|
|
0df2b05790 | ||
|
|
c36da042d0 | ||
|
|
75553fc214 | ||
|
|
7438f004c1 | ||
|
|
06dda1fb38 | ||
|
|
cdde1248d4 |
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -29,3 +29,5 @@ runs:
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
name: Upsert Comment Section
|
||||
description: >
|
||||
Manage a consolidated PR comment with independently-updatable sections.
|
||||
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
|
||||
Valid section names: "e2e", "preview", "screenshot-update".
|
||||
Multiple CI workflows can share the same comment by using the same
|
||||
comment-marker and different section-names. Each workflow upserts only
|
||||
its own section, leaving other sections intact.
|
||||
|
||||
inputs:
|
||||
pr-number:
|
||||
description: PR number to comment on
|
||||
required: true
|
||||
section-name:
|
||||
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
|
||||
description: 'Section identifier (e.g. "playwright", "storybook", "e2e", "preview")'
|
||||
required: true
|
||||
section-content:
|
||||
description: Markdown content for this section
|
||||
required: true
|
||||
comment-marker:
|
||||
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
|
||||
description: Top-level HTML comment marker shared by all sections in this comment
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token with pull-requests write permission
|
||||
@@ -38,6 +39,10 @@ runs:
|
||||
const sectionContent = process.env.INPUT_SECTION_CONTENT
|
||||
const commentMarker = process.env.INPUT_COMMENT_MARKER
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(sectionName)) {
|
||||
throw new Error(`Invalid section-name: ${sectionName}`)
|
||||
}
|
||||
|
||||
const sectionStart = `<!-- section:${sectionName}:start -->`
|
||||
const sectionEnd = `<!-- section:${sectionName}:end -->`
|
||||
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
|
||||
|
||||
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -109,3 +109,27 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No PostHog references found'
|
||||
|
||||
- name: Scan dist for Customer.io telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Customer.io references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'CustomerIoTelemetryProvider' \
|
||||
-e '@customerio/cdp-analytics-browser' \
|
||||
-e 'customerio-gist-web' \
|
||||
-e '(?i)cdp\.customer\.io' \
|
||||
-e 'Comfy\.CustomerIo' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Customer.io references found in dist assets!'
|
||||
echo 'Customer.io must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
40
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -38,16 +38,15 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Test Start
|
||||
- name: Handle Test Start — upsert playwright starting section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: playwright
|
||||
section-content: '## 🎭 Playwright: ⏳ Running...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
@@ -59,13 +58,15 @@ jobs:
|
||||
path: reports
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Handle Test Completion
|
||||
- name: Handle Test Completion — deploy and generate section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
SUMMARY_FILE: playwright-section.md
|
||||
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
@@ -74,5 +75,22 @@ jobs:
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read playwright section
|
||||
id: section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('playwright-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: playwright-section.md
|
||||
|
||||
- name: Upsert playwright section into unified report
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: playwright
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
46
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -47,6 +47,8 @@ jobs:
|
||||
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
# when using pull_request event, we have permission to comment directly
|
||||
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
# Post starting section into the unified PR report comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
@@ -240,17 +242,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting"
|
||||
- name: Upsert playwright starting section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: playwright
|
||||
section-content: '## 🎭 Playwright: ⏳ Running...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
# Deploy and upsert final playwright section for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -274,15 +275,34 @@ jobs:
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
|
||||
- name: Deploy reports and comment on PR
|
||||
- name: Deploy reports and generate section
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
SUMMARY_FILE: playwright-section.md
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read playwright section
|
||||
id: section
|
||||
if: ${{ !cancelled() && hashFiles('playwright-section.md') != '' }}
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: playwright-section.md
|
||||
|
||||
- name: Upsert playwright section into unified report
|
||||
if: ${{ !cancelled() && steps.section.outputs.content != '' }}
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: playwright
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
#### END Deployment and commenting (non-forked PRs only)
|
||||
|
||||
40
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -38,16 +38,15 @@ jobs:
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Handle Storybook Start
|
||||
- name: Handle Storybook Start — upsert storybook starting section
|
||||
if: steps.pr.outputs.skip != 'true' && 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.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: storybook
|
||||
section-content: '## 🎨 Storybook: 🚧 Building...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
@@ -58,7 +57,7 @@ jobs:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
- name: Handle Storybook Completion — deploy and generate section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -66,9 +65,28 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
|
||||
SUMMARY_FILE: storybook-section.md
|
||||
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read storybook section
|
||||
id: section
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('storybook-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: storybook-section.md
|
||||
|
||||
- name: Upsert storybook section into unified report
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.number }}
|
||||
section-name: storybook
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
84
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -37,15 +37,14 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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"
|
||||
- name: Upsert storybook starting section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: storybook
|
||||
section-content: '## 🎨 Storybook: 🚧 Building...'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
@@ -164,19 +163,38 @@ jobs:
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy Storybook and comment on PR
|
||||
- name: Deploy Storybook and generate section
|
||||
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 }}
|
||||
SUMMARY_FILE: storybook-section.md
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"$BRANCH_NAME" \
|
||||
"completed"
|
||||
|
||||
- name: Read storybook section
|
||||
id: section
|
||||
if: hashFiles('storybook-section.md') != ''
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: storybook-section.md
|
||||
|
||||
- name: Upsert storybook section into unified report
|
||||
if: steps.section.outputs.content != ''
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: storybook
|
||||
section-content: ${{ steps.section.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -208,35 +226,17 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Update comment with Chromatic URLs
|
||||
uses: actions/github-script@v8
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Upsert Chromatic section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
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
|
||||
});
|
||||
}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: chromatic
|
||||
section-content: |
|
||||
### 🎨 Chromatic Visual Tests
|
||||
- 📊 [View Chromatic Build](${{ needs.chromatic-deployment.outputs.chromatic-build-url }})
|
||||
- 📚 [View Chromatic Storybook](${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }})
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
55
.github/workflows/pr-cursor-review.yaml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# Description: Team-gated multi-model Cursor review — a thin caller for the
|
||||
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
|
||||
# truth for the panel, judge, prompts, and scripts. Triggered by the
|
||||
# 'cursor-review' label.
|
||||
#
|
||||
# Access control (team-only, two layers):
|
||||
# 1. Only users with triage permission or higher can apply a label in a public
|
||||
# repo, so the public cannot trigger this.
|
||||
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
|
||||
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
|
||||
name: 'PR: Cursor Review'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
# Re-labeling cancels an in-flight run for the same PR + label.
|
||||
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cursor-review:
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
# paths (Playwright snapshots, generated manager types).
|
||||
diff_excludes: >-
|
||||
:!**/package-lock.json
|
||||
:!**/yarn.lock
|
||||
:!**/pnpm-lock.yaml
|
||||
:!**/node_modules/**
|
||||
:!**/.claude/**
|
||||
:!**/dist/**
|
||||
:!**/vendor/**
|
||||
:!**/*.generated.*
|
||||
:!**/*.min.js
|
||||
:!**/*.min.css
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
16
.github/workflows/pr-report.yaml
vendored
@@ -140,6 +140,8 @@ jobs:
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
'<!-- PLAYWRIGHT_TEST_STATUS -->',
|
||||
'<!-- STORYBOOK_BUILD_STATUS -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
@@ -160,11 +162,19 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post PR comment
|
||||
- name: Read PR report
|
||||
id: report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: ./pr-report.md
|
||||
|
||||
- name: Upsert bundle/perf/coverage section into unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./pr-report.md
|
||||
section-name: ci-metrics
|
||||
section-content: ${{ steps.report.outputs.content }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 96 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import PlayPauseButton from './PlayPauseButton.vue'
|
||||
|
||||
type VideoTrack = {
|
||||
export type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
@@ -35,7 +35,7 @@ const {
|
||||
locale?: Locale
|
||||
src?: string
|
||||
poster?: string
|
||||
tracks?: VideoTrack[]
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
|
||||
@@ -64,6 +64,7 @@ onUnmounted(() => {
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
:tracks="tutorial.caption"
|
||||
autoplay
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,8 @@ const plans: PricingPlan[] = [
|
||||
: undefined,
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
{ text: 'pricing.plan.standard.feature2' },
|
||||
{ text: 'pricing.plan.standard.feature3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -122,11 +123,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Header -->
|
||||
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
|
||||
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p class="text-primary-comfy-canvas mt-3 text-base">
|
||||
<p class="mt-3 text-base text-primary-comfy-canvas">
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,7 +157,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
|
||||
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -172,18 +173,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="text-primary-comfy-canvas px-6 text-sm">
|
||||
<p class="px-6 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -192,7 +193,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Credits -->
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="text-primary-comfy-canvas px-6 text-sm"
|
||||
class="px-6 text-sm text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
@@ -201,7 +202,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Estimate -->
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="text-primary-comfy-canvas/80 px-6 text-xs"
|
||||
class="px-6 text-xs text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -211,17 +212,10 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="feature in plan.features"
|
||||
@@ -229,7 +223,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -269,7 +263,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
|
||||
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
@@ -287,13 +281,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<!-- Enterprise heading -->
|
||||
<h2
|
||||
v-if="plan.isEnterprise"
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="text-primary-comfy-canvas mt-2 text-sm">
|
||||
<p class="mt-2 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
@@ -301,25 +295,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
<template v-if="plan.priceKey">
|
||||
<div class="mt-6 flex items-baseline gap-1">
|
||||
<span
|
||||
class="text-primary-comfy-canvas font-formula text-5xl font-light"
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas/55 text-sm">
|
||||
<span class="text-sm text-primary-comfy-canvas/55">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
|
||||
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="text-primary-comfy-canvas mt-2 text-xs"
|
||||
class="mt-2 text-xs text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
@@ -368,7 +362,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
>
|
||||
<!-- Left side -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
|
||||
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -377,11 +371,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
{{ t(enterprisePlan.labelKey, locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
<p class="text-primary-comfy-canvas mt-3 text-sm">
|
||||
<p class="mt-3 text-sm text-primary-comfy-canvas">
|
||||
{{ t(enterprisePlan.summaryKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,7 +386,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -54,7 +54,11 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -65,10 +69,10 @@ const features: IncludedFeature[] = [
|
||||
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
|
||||
<!-- Heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
|
||||
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.included.heading', locale) }}
|
||||
</h2>
|
||||
@@ -81,7 +85,7 @@ const features: IncludedFeature[] = [
|
||||
:key="feature.titleKey"
|
||||
:class="
|
||||
index < features.length - 1
|
||||
? 'border-primary-comfy-canvas/15 border-b border-solid'
|
||||
? 'border-b border-solid border-primary-comfy-canvas/15'
|
||||
: ''
|
||||
"
|
||||
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
|
||||
@@ -99,14 +103,14 @@ const features: IncludedFeature[] = [
|
||||
v-else
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
<p class="text-sm font-medium text-primary-comfy-canvas">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
|
||||
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
|
||||
v-html="t(feature.descriptionKey, locale)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
|
||||
import type { LocalizedText, TranslationKey } from '../i18n/translations'
|
||||
|
||||
export interface LearningTutorial {
|
||||
@@ -7,6 +8,7 @@ export interface LearningTutorial {
|
||||
videoSrc: string
|
||||
href?: string
|
||||
poster?: string
|
||||
caption?: readonly VideoTrack[]
|
||||
posterTime?: number
|
||||
}
|
||||
|
||||
@@ -28,6 +30,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
@@ -38,7 +48,15 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
|
||||
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -49,6 +67,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -59,6 +85,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=be0889296f65',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -69,6 +103,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
|
||||
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
@@ -79,6 +121,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
poster:
|
||||
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
|
||||
caption: [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
|
||||
kind: 'captions',
|
||||
srclang: 'en',
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
}
|
||||
] as const
|
||||
|
||||
@@ -1244,6 +1244,10 @@ const translations = {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '可随时增加积分'
|
||||
},
|
||||
'pricing.plan.standard.feature3': {
|
||||
en: 'Run 1 workflow concurrently (via API)',
|
||||
'zh-CN': '通过 API 并发运行 1 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
|
||||
'pricing.plan.creator.summary': {
|
||||
@@ -1272,8 +1276,8 @@ const translations = {
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
en: 'Run up to 3 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 3 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
@@ -1300,8 +1304,8 @@ const translations = {
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
en: 'Run up to 5 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 5 个工作流'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
@@ -1385,9 +1389,9 @@ const translations = {
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
|
||||
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
@@ -1433,10 +1437,19 @@ const translations = {
|
||||
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA,打造专属风格。'
|
||||
},
|
||||
'pricing.included.feature11.title': {
|
||||
en: 'Run Workflows via API',
|
||||
'zh-CN': '通过 API 运行工作流'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
},
|
||||
'pricing.included.feature12.title': {
|
||||
en: 'Parallel job execution',
|
||||
'zh-CN': '并行任务执行'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
'pricing.included.feature12.description': {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ browser_tests/
|
||||
│ ├── components/ - Page object classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ ├── Templates.ts
|
||||
@@ -44,7 +43,7 @@ browser_tests/
|
||||
### Architectural Separation
|
||||
|
||||
- **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright.
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`).
|
||||
- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `SettingDialog`).
|
||||
- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`).
|
||||
- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`).
|
||||
|
||||
|
||||
436
browser_tests/assets/subgraphs/link-seed.json
Normal file
@@ -0,0 +1,436 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "seed"]]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
404
browser_tests/assets/subgraphs/proxy-seed.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [497.59999999999985, 468.79999999999995],
|
||||
"size": [510.328125, 216.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [499.9999999999999, 225.1999633789062],
|
||||
"size": [507.40625, 197.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [569.5999633789061, 732.7998535156249],
|
||||
"size": [378, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1452.7999999999997, 227.59999999999997],
|
||||
"size": [252, 72],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.1999999999998, 228.79999999999995],
|
||||
"size": [252, 84],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.20003662109363, 570.8],
|
||||
"size": [378, 130.65625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.277734375, 340.85618896484374],
|
||||
"size": [225, 184],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["3", "seed"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.40.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
439
browser_tests/assets/subgraphs/zit-seed.json
Normal file
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [498.26665242513025, 471.46666463216144],
|
||||
"size": [510.328125, 252.71875],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [500.66667683919275, 227.8666280110677],
|
||||
"size": [507.40625, 233.171875],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [570.266591389974, 735.4665120442708],
|
||||
"size": [378, 216],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [13]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1453.466512044271, 230.26666768391925],
|
||||
"size": [252, 138],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1743.866658528646, 231.46666463216144],
|
||||
"size": [252, 148],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [33.866689046223996, 573.4666951497395],
|
||||
"size": [378, 196],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [10]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [3, 5]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"pos": [1145.9444173177085, 343.52284749348956],
|
||||
"size": [225, 220],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [14]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["-1", "seed"],
|
||||
["3", "control_after_generate"]
|
||||
]
|
||||
},
|
||||
"widgets_values": [1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"],
|
||||
[10, 4, 0, 10, 0, "MODEL"],
|
||||
[11, 6, 0, 10, 1, "CONDITIONING"],
|
||||
[12, 7, 0, 10, 2, "CONDITIONING"],
|
||||
[13, 5, 0, 10, 3, "LATENT"],
|
||||
[14, 10, 0, 8, 0, "LATENT"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "5526b801-03ef-4797-9052-cbc171512972",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [1031.12, 372.62745605468746, 120, 140]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"localized_name": "model",
|
||||
"pos": [1131.12, 392.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "positive",
|
||||
"pos": [1131.12, 412.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [6],
|
||||
"localized_name": "negative",
|
||||
"pos": [1131.12, 432.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"localized_name": "latent_image",
|
||||
"pos": [1131.12, 452.62745605468746]
|
||||
},
|
||||
{
|
||||
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"pos": [1131.12, 472.62745605468746]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [7],
|
||||
"localized_name": "LATENT",
|
||||
"pos": [1792.7199999999998, 428.62745605468746]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [1247.1199707031249, 272.23994140624995],
|
||||
"size": [453.59375, 380.765625],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"increment",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 3,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "Vue"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
},
|
||||
"workflowRendererVersion": "Vue",
|
||||
"frontendVersion": "1.35.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class VueNodeHelpers {
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
getNodeByTitle(title: string | RegExp): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||
})
|
||||
@@ -145,7 +145,7 @@ export class VueNodeHelpers {
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getNodeIdByTitle(title: string | RegExp): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -163,7 +163,7 @@ export class VueNodeHelpers {
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getFixtureByTitle(title: string | RegExp): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ManageGroupNode {
|
||||
footer: Locator
|
||||
header: Locator
|
||||
|
||||
constructor(
|
||||
readonly page: Page,
|
||||
readonly root: Locator
|
||||
) {
|
||||
this.footer = root.locator('footer')
|
||||
this.header = root.locator('header')
|
||||
}
|
||||
|
||||
async setLabel(name: string, label: string) {
|
||||
const active = this.root.locator('.comfy-group-manage-node-page.active')
|
||||
const input = active.getByPlaceholder(name)
|
||||
await input.fill(label)
|
||||
}
|
||||
|
||||
async save() {
|
||||
await this.footer.getByText('Save').click()
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.footer.getByText('Close').click()
|
||||
}
|
||||
|
||||
get selectedNodeTypeSelect(): Locator {
|
||||
return this.header.locator('select').first()
|
||||
}
|
||||
|
||||
async getSelectedNodeType() {
|
||||
return await this.selectedNodeTypeSelect.inputValue()
|
||||
}
|
||||
|
||||
async selectNode(name: string) {
|
||||
const list = this.root.locator('.comfy-group-manage-list-items')
|
||||
const item = list.getByText(name)
|
||||
await item.click()
|
||||
}
|
||||
|
||||
async changeTab(name: 'Inputs' | 'Widgets' | 'Outputs') {
|
||||
const header = this.root.locator('.comfy-group-manage-node header')
|
||||
const tab = header.getByText(name)
|
||||
await tab.click()
|
||||
}
|
||||
}
|
||||
45
browser_tests/fixtures/utils/flashDetector.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
function flagAttributeFor(testId: string) {
|
||||
const encoded = Array.from(testId, (ch) =>
|
||||
ch.charCodeAt(0).toString(16)
|
||||
).join('')
|
||||
return `data-flashed-${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the first time an element matching `[data-testid="<testId>"]` is
|
||||
* present and rendered, sampled every frame via `requestAnimationFrame` from
|
||||
* page load. Catches a dialog that mounts and unmounts within a few frames,
|
||||
* which `toBeHidden()` (final state only) cannot.
|
||||
*
|
||||
* Must be called before navigation (e.g. before `comfyPage.setup()`).
|
||||
*/
|
||||
export async function trackElementFlash(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<{ hasFlashed: () => Promise<boolean> }> {
|
||||
const flagAttribute = flagAttributeFor(testId)
|
||||
|
||||
await page.addInitScript(
|
||||
({ id, attribute }: { id: string; attribute: string }) => {
|
||||
const sample = () => {
|
||||
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
|
||||
if (el instanceof HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
document.documentElement.setAttribute(attribute, 'true')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ id: testId, attribute: flagAttribute }
|
||||
)
|
||||
|
||||
return {
|
||||
hasFlashed: async () =>
|
||||
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
@@ -525,14 +524,6 @@ export class NodeReference {
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
return new ManageGroupNode(
|
||||
this.comfyPage.page,
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
async navigateIntoSubgraph() {
|
||||
const titleHeight = await this.comfyPage.page.evaluate(() => {
|
||||
return window.LiteGraph!['NODE_TITLE_HEIGHT']
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
@@ -8,8 +7,13 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
interface ResolvedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
source: ResolvedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
@@ -20,23 +24,22 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the promoted widgets of a subgraph host node from the live graph.
|
||||
*
|
||||
* Promoted widgets are now store-backed: a host input is promoted iff it
|
||||
* carries a `widgetId`, and its interior source identity is resolved on demand
|
||||
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
|
||||
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
|
||||
* denormalization, so the helper reflects the real projection rather than a
|
||||
* deleted widget-object contract.
|
||||
*/
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -44,21 +47,49 @@ export async function getPromotedWidgets(
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
const previewExposures = node?.serialize()?.properties?.previewExposures
|
||||
if (!node?.isSubgraphNode?.())
|
||||
return { widgetSources: [], previewExposures }
|
||||
|
||||
const { subgraph } = node
|
||||
const resolveSource = (
|
||||
inputName: string
|
||||
): ResolvedWidgetSource | undefined => {
|
||||
const inputSlot = subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) return undefined
|
||||
for (const linkId of inputSlot.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
if (inputNode.isSubgraphNode?.()) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const widgetSources = (node.inputs ?? []).flatMap((input) => {
|
||||
if (!input.widgetId) return []
|
||||
const source = resolveSource(input.name)
|
||||
return source ? [source] : []
|
||||
})
|
||||
return { widgetSources, previewExposures }
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
@@ -67,7 +98,7 @@ export async function getPromotedWidgets(
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...widgetSources.map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
135
browser_tests/tests/cloudSurveyGate.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
|
||||
* must still route a not-completed user there. Drives a raw `page` so the cloud
|
||||
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
|
||||
* devtools backend during setup).
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
async function mockSurveyNotCompleted(page: Page) {
|
||||
await page.route('**/api/settings/onboarding_survey', (r) =>
|
||||
r.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Transient auth failure: a stale workspace token makes the authenticated
|
||||
// survey check 401 — the hiccup that used to bounce working users.
|
||||
async function mockSurveyTransient401(page: Page) {
|
||||
await page.route('**/api/settings/onboarding_survey', (r) =>
|
||||
r.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User authentication required'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(APP_URL)
|
||||
|
||||
// The full app boots — CloudSurveyView is a standalone onboarding view, so
|
||||
// reaching the extension manager proves we landed on the working app and
|
||||
// the transient 401 was treated as "completed", not a bounce.
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/\/cloud\/survey/)
|
||||
})
|
||||
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
await page.goto(APP_URL)
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
|
||||
})
|
||||
})
|
||||
@@ -1,298 +1,50 @@
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
/**
|
||||
* Group nodes are a deprecated feature. Workflows that still contain group nodes
|
||||
* are auto-converted to subgraphs on load (with accepted lossiness).
|
||||
*/
|
||||
test.describe('Group node migration', { tag: '@node' }, () => {
|
||||
test('Auto-converts a loaded group node into a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
})
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await libraryTab.open()
|
||||
const state = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return {
|
||||
groupNodeInstances: graph.nodes.filter((n) =>
|
||||
String(n.type).startsWith('workflow>')
|
||||
).length,
|
||||
subgraphCount: graph.subgraphs.size,
|
||||
hasGroupNodesExtra: !!graph.extra?.groupNodes
|
||||
}
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
})
|
||||
expect(state.groupNodeInstances).toBe(0)
|
||||
expect(state.subgraphCount).toBe(1)
|
||||
expect(state.hasGroupNodesExtra).toBe(false)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
test(
|
||||
'Loads a legacy ("/") separator group node without error and converts it',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
// Verify the node is added to the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify the node is removed from the bookmarks tab
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('.p-tree-node-label.tree-explorer-node-label')
|
||||
.first()
|
||||
.hover()
|
||||
await expect(
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'groupnodes/group_node_identical_nodes_hidden_inputs'
|
||||
)
|
||||
|
||||
const groupNodeId = 19
|
||||
const groupNodeName = 'two_VAE_decode'
|
||||
|
||||
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeName) => {
|
||||
const {
|
||||
extra: { groupNodes }
|
||||
} = window.app!.graph!
|
||||
const { nodes } = groupNodes![nodeName]
|
||||
return nodes.reduce(
|
||||
(acc, node) => acc + (node.inputs?.length ?? 0),
|
||||
0
|
||||
)
|
||||
}, groupNodeName)
|
||||
)
|
||||
.toBe(4)
|
||||
|
||||
// Verify there are 2 visible inputs (2 have been hidden in config)
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node!.inputs.length
|
||||
}, groupNodeId)
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
return !!window.LiteGraph!.registered_node_types[nodeType]
|
||||
}, GROUP_NODE_TYPE)
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
}
|
||||
|
||||
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
|
||||
.getFolder(GROUP_NODE_CATEGORY)
|
||||
.count()
|
||||
return groupNodesFolderCt === 1
|
||||
}
|
||||
|
||||
const verifyNodeLoaded = async (
|
||||
comfyPage: ComfyPage,
|
||||
expectedCount: number
|
||||
) => {
|
||||
expect(
|
||||
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
||||
).toHaveLength(expectedCount)
|
||||
await expect.poll(() => isRegisteredLitegraph(comfyPage)).toBe(true)
|
||||
await expect.poll(() => isRegisteredNodeDefStore(comfyPage)).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
test('Copies and pastes group node within the same workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 2)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node into a newly created blank workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node across different workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.clipboard.paste()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
|
||||
test('Serializes group node after copy and paste across workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.clipboard.paste()
|
||||
const currentGraphState = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph!.serialize()
|
||||
)
|
||||
|
||||
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
currentGraphState as ComfyWorkflowJSON
|
||||
)
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
103
browser_tests/tests/maskEditorLoadSave.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -347,55 +347,6 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Should handle custom node documentation paths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// First load workflow with custom node
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/group_node_v1.3.3')
|
||||
|
||||
// Mock custom node documentation with fallback
|
||||
await comfyPage.page.route(
|
||||
'**/extensions/*/docs/*/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({ status: 404 })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Custom Node Documentation
|
||||
|
||||
This is documentation for a custom node.
|
||||
|
||||

|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes.map((n) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.nodeOps.getNodeRefById(nodeRefs[0])
|
||||
await selectNodeWithPan(comfyPage, firstNode)
|
||||
}
|
||||
|
||||
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
if (await helpButton.isVisible()) {
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('Custom Node Documentation')
|
||||
|
||||
// Check image path for custom nodes
|
||||
const image = helpPage.locator('img[alt="Custom Image"]')
|
||||
await expect(image).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
|
||||
// Mock response with potentially dangerous content
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
|
||||
@@ -131,6 +131,14 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
|
||||
if (mode.vueNodesEnabled) {
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'denoise')
|
||||
.locator('input')
|
||||
).toHaveValue(/^1(?:\.0+)?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
|
||||
@@ -34,6 +34,22 @@ test.describe('Properties panel - Node selection', () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'a linked widget is disabled',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
const seed = panel.contentArea.getByLabel('seed').locator('input')
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const intNode = await comfyPage.vueNodes.getFixtureByTitle(/Int/)
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await ksampler.select()
|
||||
await expect(seed).toBeEnabled()
|
||||
await intNode.getSlot('INT').dragTo(ksampler.getSlot('seed'))
|
||||
await expect(seed).toBeDisabled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 98 KiB |
@@ -15,6 +15,10 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
//
|
||||
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
|
||||
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
|
||||
// full store, so the per-filter count assertions still cover the behavior.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
@@ -179,7 +183,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
@@ -1105,3 +1105,56 @@ test.describe('Assets sidebar - drag and drop', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
})
|
||||
|
||||
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job1',
|
||||
preview_output: {
|
||||
filename: `1.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job2',
|
||||
preview_output: {
|
||||
filename: `2.png`,
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job2',
|
||||
preview_output: {
|
||||
filename: `3.png`,
|
||||
type: 'input',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(3)
|
||||
for (const [index, expectedName] of [
|
||||
[0, '1.png [temp]'],
|
||||
[1, '2.png [output]'],
|
||||
[2, '3.png']
|
||||
] as const) {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await assetsTab.assetCards.nth(index).scrollIntoViewIfNeeded()
|
||||
await assetsTab.assetCards.nth(index).click({ button: 'right' })
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await comfyPage.contextMenu.primeVueMenu.getByText('Insert as node').click()
|
||||
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const fileWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileWidget.getValue()).toBe(expectedName)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -217,6 +217,14 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Each promoted input must surface its own source value, so assert the
|
||||
// name->value mapping rather than the first textbox in DOM order.
|
||||
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
|
||||
value: /Inner 1/,
|
||||
value_1: /Inner 2/,
|
||||
value_1_1: /Inner 3/
|
||||
}
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -228,11 +236,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNode.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
@@ -271,11 +284,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
for (const [inputName, expectedValue] of Object.entries(
|
||||
EXPECTED_VALUE_BY_INPUT
|
||||
)) {
|
||||
const valueWidget = outerNodeAfter.getByRole('textbox', {
|
||||
name: inputName,
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,6 +53,22 @@ test.describe(
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator(String(subgraphNode.id))
|
||||
.getByRole('textbox', { name: 'text', exact: true })
|
||||
await expect(promotedTextarea).toHaveCount(1)
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
|
||||
50
browser_tests/tests/subgraph/subgraphSeed.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
wstest(
|
||||
'Seed handling',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
async function verifySeedControl(initializeState = true) {
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
|
||||
const { input, valueControl } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
|
||||
if (initializeState) {
|
||||
await input.fill('1')
|
||||
await valueControl.click()
|
||||
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
await execution.run()
|
||||
await expect.soft(input).toHaveValue('2')
|
||||
}
|
||||
|
||||
await test.step('seed updates on generation', async () => {
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
await test.step('subgraph seed updates on generation', async () => {
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
await verifySeedControl()
|
||||
})
|
||||
|
||||
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
|
||||
await test.step(`seed updates for old workflow: ${w}`, async () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
|
||||
await verifySeedControl(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -484,6 +484,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Assert against the visible textbox the user sees, not the internal
|
||||
// graph/widget projection.
|
||||
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(1)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
@@ -497,31 +505,58 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
await comfyExpect(promotedTextWidgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test(
|
||||
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const editedValue = 'Edited prompt that must survive cloning'
|
||||
const originalTextbox = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(originalTextbox).toBeVisible()
|
||||
await expect(originalTextbox).toHaveValue('')
|
||||
await originalTextbox.fill(editedValue)
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
async function collectSubgraphNodeIds() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => (await collectSubgraphNodeIds()).length)
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeIds = await collectSubgraphNodeIds()
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const textbox = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(
|
||||
textbox,
|
||||
`node ${nodeId} promoted text widget reset to default after clone`
|
||||
).toHaveValue(editedValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
|
||||
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -505,3 +506,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
expect(popup.url()).toEqual(tutorialUrl)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Templates deeplink (new user)',
|
||||
{ tag: ['@slow', '@workflow'] },
|
||||
() => {
|
||||
test('templates dialog never flashes when first-time user opens a template link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templatesFlash = await trackElementFlash(
|
||||
comfyPage.page,
|
||||
TestIds.templates.content
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?template=default'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
expect(await templatesFlash.hasFlashed()).toBe(false)
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,6 +177,30 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -1270,38 +1270,3 @@ test(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Spacebar pan', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
const initialOffset = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await test.step('Setup link drag', async () => {
|
||||
await comfyPage.searchBoxV2.addNode('Load Diffusion')
|
||||
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Diff')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await loadNode.getSlot('MODEL').hover()
|
||||
await comfyPage.page.mouse.down()
|
||||
await ksampler.getSlot('model').hover()
|
||||
expect(await comfyPage.canvasOps.getOffset()).toEqual(initialOffset)
|
||||
})
|
||||
|
||||
await test.step('Holding space initiates a pan', async () => {
|
||||
await comfyPage.page.keyboard.down(' ')
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.page.keyboard.up(' ')
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(initialOffset)
|
||||
})
|
||||
|
||||
await test.step('Mouse remains over model after pan', async () => {
|
||||
await comfyPage.page.mouse.up()
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph?.nodes?.at(-1)?.outputs?.[0]?.links?.length === 1
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -139,6 +140,46 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -23,4 +25,42 @@ test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('shows a spinner during upload', async ({ comfyPage }) => {
|
||||
let releaseUpload: () => void = () => {}
|
||||
const uploadResponse: UploadImageResponse = { name: 'spinner-test.png' }
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve
|
||||
})
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(uploadResponse)
|
||||
})
|
||||
})
|
||||
for (const nodeName of ['Load Image', 'Load Video', 'Load Audio']) {
|
||||
await test.step(`for ${nodeName}`, async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBoxV2.addNode(nodeName)
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(nodeName)
|
||||
const fileInput = node.locator('input[type="file"]')
|
||||
const spinner = node.getByRole('status')
|
||||
|
||||
await expect(spinner).toBeHidden()
|
||||
await fileInput.setInputFiles({
|
||||
name: 'spinner-test.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('test')
|
||||
})
|
||||
|
||||
await expect(spinner).toBeVisible()
|
||||
releaseUpload()
|
||||
await expect(spinner).toBeHidden()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
11
codecov.yml
@@ -4,3 +4,14 @@ comment:
|
||||
require_changes: false
|
||||
require_base: false
|
||||
require_head: true
|
||||
|
||||
# Carry forward the last known coverage for a flag when its upload is missing or
|
||||
# late. The `e2e` flag is uploaded by a separate workflow_run job that can fail
|
||||
# or arrive after Codecov has already computed the patch status; without this,
|
||||
# E2E-only code paths show up as patch misses and the patch status fails. See
|
||||
# https://docs.codecov.com/docs/carryforward-flags
|
||||
flags:
|
||||
unit:
|
||||
carryforward: true
|
||||
e2e:
|
||||
carryforward: true
|
||||
|
||||
@@ -29,31 +29,30 @@ The following table lists ALL core extensions in the system as of 2025-01-30:
|
||||
|
||||
### Main Extensions
|
||||
|
||||
| Extension | Description | Category |
|
||||
| ----------------------- | ------------------------------------------------------------- | --------- |
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
|
||||
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
|
||||
| editAttention.ts | Implements attention editing functionality | Text |
|
||||
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
|
||||
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
|
||||
| groupNodeManage.ts | Provides group node management operations | Graph |
|
||||
| groupOptions.ts | Handles group node configuration options | Graph |
|
||||
| index.ts | Main extension registration and coordination | Core |
|
||||
| load3d.ts | Supports 3D model loading and visualization | 3D |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
|
||||
| nodeTemplates.ts | Provides node template functionality | Templates |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
|
||||
| previewAny.ts | Universal preview functionality for various data types | Preview |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
|
||||
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
|
||||
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
|
||||
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
|
||||
| slotDefaults.ts | Manages default values for node slots | Nodes |
|
||||
| uploadAudio.ts | Handles audio file upload functionality | Audio |
|
||||
| uploadImage.ts | Handles image upload functionality | Image |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities | Media |
|
||||
| widgetInputs.ts | Implements various widget input types | Widgets |
|
||||
| Extension | Description | Category |
|
||||
| ----------------------- | ------------------------------------------------------------ | --------- |
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
|
||||
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
|
||||
| editAttention.ts | Implements attention editing functionality | Text |
|
||||
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
|
||||
| groupNode.ts | Migrates deprecated group nodes to subgraphs on load | Graph |
|
||||
| groupOptions.ts | Handles group node configuration options | Graph |
|
||||
| index.ts | Main extension registration and coordination | Core |
|
||||
| load3d.ts | Supports 3D model loading and visualization | 3D |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
|
||||
| nodeTemplates.ts | Provides node template functionality | Templates |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
|
||||
| previewAny.ts | Universal preview functionality for various data types | Preview |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
|
||||
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
|
||||
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
|
||||
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
|
||||
| slotDefaults.ts | Manages default values for node slots | Nodes |
|
||||
| uploadAudio.ts | Handles audio file upload functionality | Audio |
|
||||
| uploadImage.ts | Handles image upload functionality | Image |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities | Media |
|
||||
| widgetInputs.ts | Implements various widget input types | Widgets |
|
||||
|
||||
### Conditional Lines Subdirectory
|
||||
|
||||
|
||||
5
global.d.ts
vendored
@@ -49,6 +49,11 @@ interface Window {
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
customer_io?: {
|
||||
write_key?: string
|
||||
site_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -23,6 +23,7 @@
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@customerio/cdp-analytics-browser": "catalog:",
|
||||
"@formkit/auto-animate": "catalog:",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 1.6 KiB |
137
pnpm-lock.yaml
generated
@@ -21,6 +21,9 @@ catalogs:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
@@ -447,6 +450,9 @@ importers:
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
'@customerio/cdp-analytics-browser':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.3
|
||||
'@formkit/auto-animate':
|
||||
specifier: 'catalog:'
|
||||
version: 0.9.0
|
||||
@@ -1441,6 +1447,15 @@ packages:
|
||||
peerDependencies:
|
||||
postcss-selector-parser: ^7.0.0
|
||||
|
||||
'@customerio/cdp-analytics-browser@0.5.3':
|
||||
resolution: {integrity: sha512-P4lBz+P2iCekq+DOETiAtSfdMyNVQd7OjXhocjffjPtyBJ0ADhpvYuNZAT9R+q2AFrDTx0m8cuyJAtEG+qiXPQ==}
|
||||
|
||||
'@customerio/cdp-analytics-core@0.5.3':
|
||||
resolution: {integrity: sha512-mjR0dyzsX8UjMAh22bT5ByiIEYwtpnNhc9TlHTk2nGPhFnMctSsn9KuMXD9BmfSFcjdmTPg+iABOq68yyPBPHg==}
|
||||
|
||||
'@customerio/jist@0.1.8':
|
||||
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
|
||||
|
||||
'@cyberalien/svg-utils@1.2.15':
|
||||
resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==}
|
||||
|
||||
@@ -2382,6 +2397,14 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@lukeed/csprng@1.1.0':
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@lukeed/uuid@2.0.1':
|
||||
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -3275,6 +3298,18 @@ packages:
|
||||
'@rushstack/ts-command-line@5.3.1':
|
||||
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
|
||||
|
||||
'@segment/analytics.js-video-plugins@0.2.1':
|
||||
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
|
||||
|
||||
'@segment/facade@3.4.10':
|
||||
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
|
||||
|
||||
'@segment/isodate-traverse@1.1.1':
|
||||
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
|
||||
|
||||
'@segment/isodate@1.0.3':
|
||||
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
|
||||
|
||||
'@sentry-internal/browser-utils@10.32.1':
|
||||
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5037,6 +5072,9 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
customerio-gist-web@3.23.2:
|
||||
resolution: {integrity: sha512-oCM7WNEx/3cmEG1qQCKWrMwOtU+h41TTKJICNEb7Wj/1jR6+RJsj3b+3N+5u9TxgvUMusmLFvnVvqshU017eHA==}
|
||||
|
||||
cva@1.0.0-beta.4:
|
||||
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
|
||||
peerDependencies:
|
||||
@@ -6255,6 +6293,10 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.1:
|
||||
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
js-cookie@3.0.7:
|
||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6887,6 +6929,9 @@ packages:
|
||||
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
new-date@1.0.3:
|
||||
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
|
||||
|
||||
@@ -6936,6 +6981,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
obj-case@0.2.1:
|
||||
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7776,6 +7824,9 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
spark-md5@3.0.2:
|
||||
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8175,6 +8226,12 @@ packages:
|
||||
unescape-js@1.1.4:
|
||||
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
|
||||
|
||||
unfetch@3.1.2:
|
||||
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
|
||||
|
||||
unfetch@4.2.0:
|
||||
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -8360,6 +8417,10 @@ packages:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.2.0:
|
||||
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||
peerDependencies:
|
||||
@@ -8640,8 +8701,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.4:
|
||||
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9490,6 +9551,30 @@ snapshots:
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.1
|
||||
|
||||
'@customerio/cdp-analytics-browser@0.5.3':
|
||||
dependencies:
|
||||
'@customerio/cdp-analytics-core': 0.5.3
|
||||
'@lukeed/uuid': 2.0.1
|
||||
'@segment/analytics.js-video-plugins': 0.2.1
|
||||
'@segment/facade': 3.4.10
|
||||
customerio-gist-web: 3.23.2
|
||||
dset: 3.1.4
|
||||
js-cookie: 3.0.1
|
||||
node-fetch: 2.7.0
|
||||
spark-md5: 3.0.2
|
||||
tslib: 2.8.1
|
||||
unfetch: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@customerio/cdp-analytics-core@0.5.3':
|
||||
dependencies:
|
||||
'@lukeed/uuid': 2.0.1
|
||||
dset: 3.1.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@customerio/jist@0.1.8': {}
|
||||
|
||||
'@cyberalien/svg-utils@1.2.15':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -10450,6 +10535,12 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@lukeed/uuid@2.0.1':
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.13
|
||||
@@ -11079,6 +11170,23 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@segment/analytics.js-video-plugins@0.2.1':
|
||||
dependencies:
|
||||
unfetch: 3.1.2
|
||||
|
||||
'@segment/facade@3.4.10':
|
||||
dependencies:
|
||||
'@segment/isodate-traverse': 1.1.1
|
||||
inherits: 2.0.4
|
||||
new-date: 1.0.3
|
||||
obj-case: 0.2.1
|
||||
|
||||
'@segment/isodate-traverse@1.1.1':
|
||||
dependencies:
|
||||
'@segment/isodate': 1.0.3
|
||||
|
||||
'@segment/isodate@1.0.3': {}
|
||||
|
||||
'@sentry-internal/browser-utils@10.32.1':
|
||||
dependencies:
|
||||
'@sentry/core': 10.32.1
|
||||
@@ -11323,7 +11431,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.4
|
||||
vue-component-type-helpers: 3.3.5
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -13103,6 +13211,11 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
customerio-gist-web@3.23.2:
|
||||
dependencies:
|
||||
'@customerio/jist': 0.1.8
|
||||
uuid: 14.0.0
|
||||
|
||||
cva@1.0.0-beta.4(typescript@5.9.3):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -14475,6 +14588,8 @@ snapshots:
|
||||
js-cookie: 3.0.7
|
||||
nopt: 7.2.1
|
||||
|
||||
js-cookie@3.0.1: {}
|
||||
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-stringify@1.0.2: {}
|
||||
@@ -15281,6 +15396,10 @@ snapshots:
|
||||
|
||||
neotraverse@0.6.18: {}
|
||||
|
||||
new-date@1.0.3:
|
||||
dependencies:
|
||||
'@segment/isodate': 1.0.3
|
||||
|
||||
nlcst-to-string@4.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -15323,6 +15442,8 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
tinyexec: 1.0.4
|
||||
|
||||
obj-case@0.2.1: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -16440,6 +16561,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@@ -16854,6 +16977,10 @@ snapshots:
|
||||
dependencies:
|
||||
string.fromcodepoint: 0.2.1
|
||||
|
||||
unfetch@3.1.2: {}
|
||||
|
||||
unfetch@4.2.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -17044,6 +17171,8 @@ snapshots:
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -17469,7 +17598,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.4: {}
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -15,6 +15,7 @@ catalog:
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@customerio/cdp-analytics-browser': ^0.5.3
|
||||
'@eslint/js': ^10.0.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
|
||||
# Deploy Playwright test reports to Cloudflare Pages and write section markdown.
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
#
|
||||
# When SUMMARY_FILE env var is set, the generated markdown is written there
|
||||
# instead of posted as a standalone GitHub comment. The caller is then
|
||||
# responsible for upserting that content into the unified PR report via the
|
||||
# upsert-comment-section action.
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -103,17 +108,28 @@ deploy_report() {
|
||||
echo "failed"
|
||||
}
|
||||
|
||||
# Post or update GitHub comment
|
||||
# Post or update GitHub comment, or write to SUMMARY_FILE if set.
|
||||
# When SUMMARY_FILE is set, the caller (workflow) is responsible for upserting
|
||||
# the content into the unified PR report via upsert-comment-section.
|
||||
# The gh-api branch below is unused in CI (SUMMARY_FILE is always set there);
|
||||
# it is retained for local/standalone runs that post a comment directly.
|
||||
post_comment() {
|
||||
body="$1"
|
||||
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
printf '%s\n' "$body" > "$SUMMARY_FILE" || { echo "Failed to write $SUMMARY_FILE" >&2; exit 1; }
|
||||
echo "Wrote playwright section to $SUMMARY_FILE" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
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" \
|
||||
@@ -126,15 +142,20 @@ post_comment() {
|
||||
echo "GitHub CLI not available, outputting comment:"
|
||||
cat "$temp_file"
|
||||
fi
|
||||
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post concise starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
# When writing to SUMMARY_FILE, omit the standalone marker (the upsert
|
||||
# action uses its own section delimiters).
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
comment="## 🎭 Playwright: ⏳ Running..."
|
||||
else
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: ⏳ Running..."
|
||||
fi
|
||||
post_comment "$comment"
|
||||
|
||||
else
|
||||
@@ -281,9 +302,14 @@ else
|
||||
flaky_note=" · $total_flaky flaky"
|
||||
fi
|
||||
|
||||
# Generate compact single-line comment
|
||||
comment="$COMMENT_MARKER
|
||||
# Generate compact single-line comment (omit standalone marker when writing
|
||||
# to SUMMARY_FILE — the upsert action adds its own section delimiters).
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
comment="## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
else
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
fi
|
||||
|
||||
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Deploy Storybook to Cloudflare Pages and comment on PR
|
||||
# Deploy Storybook to Cloudflare Pages and write section markdown.
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
#
|
||||
# When SUMMARY_FILE env var is set, the generated markdown is written there
|
||||
# instead of posted as a standalone GitHub comment. The caller is then
|
||||
# responsible for upserting that content into the unified PR report via the
|
||||
# upsert-comment-section action.
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -91,17 +96,26 @@ deploy_storybook() {
|
||||
echo "failed"
|
||||
}
|
||||
|
||||
# Post or update GitHub comment
|
||||
# Post or update GitHub comment, or write to SUMMARY_FILE if set.
|
||||
# When SUMMARY_FILE is set, the caller (workflow) is responsible for upserting
|
||||
# the content into the unified PR report via upsert-comment-section.
|
||||
post_comment() {
|
||||
body="$1"
|
||||
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
printf '%s\n' "$body" > "$SUMMARY_FILE" || { echo "Failed to write $SUMMARY_FILE" >&2; exit 1; }
|
||||
echo "Wrote storybook section to $SUMMARY_FILE" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
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" \
|
||||
@@ -113,15 +127,20 @@ post_comment() {
|
||||
echo "GitHub CLI not available, outputting comment:"
|
||||
cat "$temp_file"
|
||||
fi
|
||||
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
# When writing to SUMMARY_FILE, omit the standalone marker (the upsert
|
||||
# action uses its own section delimiters).
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
comment="## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
else
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
fi
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
@@ -197,10 +216,18 @@ elif [ "$STATUS" = "completed" ]; then
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
# Omit standalone marker when writing to SUMMARY_FILE — the upsert action
|
||||
# adds its own section delimiters.
|
||||
if [ -n "${SUMMARY_FILE:-}" ]; then
|
||||
comment="$header
|
||||
|
||||
$details"
|
||||
else
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
|
||||
fi
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -316,12 +316,16 @@ function renderFullReport(
|
||||
lines.push(
|
||||
`⚠️ **${flaggedRows.length} regression${flaggedRows.length > 1 ? 's' : ''} detected**`,
|
||||
'',
|
||||
'<details><summary>Show regressions</summary>',
|
||||
'',
|
||||
...tableHeader,
|
||||
...flaggedRows,
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
)
|
||||
} else {
|
||||
lines.push('No regressions detected.', '')
|
||||
lines.push('✅ No regressions detected.', '')
|
||||
}
|
||||
|
||||
lines.push(
|
||||
@@ -393,6 +397,8 @@ function renderColdStartReport(
|
||||
lines.push(
|
||||
`> ℹ️ Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
|
||||
'',
|
||||
'<details><summary>All metrics (cold start)</summary>',
|
||||
'',
|
||||
'| Metric | Baseline | PR | Δ |',
|
||||
'|--------|----------|-----|---|'
|
||||
)
|
||||
@@ -430,6 +436,7 @@ function renderColdStartReport(
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', '</details>')
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -438,7 +445,10 @@ function renderNoBaselineReport(
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
'No baseline found — showing absolute values.\n',
|
||||
'> ℹ️ No baseline found — significance unavailable.',
|
||||
'',
|
||||
'<details><summary>Absolute values</summary>',
|
||||
'',
|
||||
'| Metric | Value |',
|
||||
'|--------|-------|'
|
||||
)
|
||||
@@ -449,6 +459,7 @@ function renderNoBaselineReport(
|
||||
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
|
||||
}
|
||||
}
|
||||
lines.push('', '</details>')
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
|
||||
default: {
|
||||
name: 'ComfyQueueButton',
|
||||
template: '<div data-testid="queue-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
|
||||
default: {
|
||||
name: 'SubscribeToRun',
|
||||
template: '<div data-testid="subscribe-to-run-button" />'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderWrapper() {
|
||||
return render(CloudRunButtonWrapper)
|
||||
}
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-to-run-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
function removeSelectedWidgetId(widgetId: WidgetId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
const widgetId = widget.widgetId
|
||||
if (!widgetId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
[entry.widgetId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
:remove="() => removeSelectedWidgetId(entry.widgetId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
|
||||
@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
const { widgetId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
return vueWidget.entityId === entityId
|
||||
return vueWidget.widgetId === widgetId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: entityId,
|
||||
key: widgetId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -22,18 +23,29 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs: [],
|
||||
isSubgraphNode: () => false,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
inputs,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: []
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
@@ -88,4 +100,27 @@ describe('useResolvedSelectedInputs', () => {
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('resolves promoted subgraph inputs from their host input widgetId', () => {
|
||||
const node = makeSubgraphNode(1, [
|
||||
fromPartial<INodeInputSlot>({
|
||||
name: 'seed',
|
||||
label: 'renamed_seed',
|
||||
widgetId: entitySeed
|
||||
})
|
||||
])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
|
||||
expect(resolved.value[0]).toMatchObject({
|
||||
status: 'resolved',
|
||||
node,
|
||||
displayName: 'seed',
|
||||
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
@@ -20,7 +21,7 @@ export type ResolvedSelection =
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
widgetId: WidgetId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
@@ -54,16 +55,19 @@ export function useResolvedSelectedInputs() {
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
([widgetId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetId(widgetId)) return []
|
||||
const { nodeId, name } = parseWidgetId(widgetId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
const widgets = node?.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: node?.widgets
|
||||
const widget = widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
return [{ status: 'unknown', widgetId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
{ status: 'resolved', widgetId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,12 +7,26 @@ import { createI18n } from 'vue-i18n'
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -62,7 +76,6 @@ function createTestI18n() {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
@@ -108,6 +121,10 @@ function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
@@ -116,17 +133,12 @@ describe('ErrorOverlay', () => {
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -137,6 +149,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -145,21 +163,19 @@ describe('ErrorOverlay', () => {
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.getByTestId('error-overlay-dismiss')).toHaveAccessibleName(
|
||||
'Close'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -170,6 +186,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,47 +7,35 @@
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto relative flex w-fit max-w-120 min-w-80 flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
<div class="flex w-full items-start gap-2 pr-8">
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<div
|
||||
class="flex w-full items-start gap-2 pr-8"
|
||||
data-testid="error-overlay-messages"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
data-testid="error-overlay-dismiss"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
@@ -58,6 +46,17 @@
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-2 right-2 size-6 rounded-sm"
|
||||
data-testid="error-overlay-dismiss"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -8,12 +8,26 @@ import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -44,7 +58,6 @@ function createTestI18n() {
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
@@ -92,20 +105,19 @@ function mountOverlayState() {
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -116,6 +128,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -125,17 +143,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -152,6 +165,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -164,17 +183,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -190,6 +204,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -202,24 +222,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -237,6 +245,19 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -247,6 +268,44 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mockErrorGroups.missingMediaGroups.value = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'image.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
count: 1,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -261,17 +320,6 @@ describe('useErrorOverlayState', () => {
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -281,6 +329,147 @@ describe('useErrorOverlayState', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one missing model referenced by multiple nodes', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'missing.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Model missing',
|
||||
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
|
||||
count: 1,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Import a model, or open the node to replace it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one execution group with multiple errors', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:required_input_missing',
|
||||
displayTitle: 'Missing connection',
|
||||
displayMessage: 'Required input slots have no connection feeding them.',
|
||||
count: 2,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{ message: 'KSampler is missing model' },
|
||||
{ message: 'KSampler is missing positive' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses aggregate copy for one missing model group with multiple rows', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'first.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'first.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
|
||||
},
|
||||
{
|
||||
name: 'second.safetensors',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'second.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.',
|
||||
count: 2,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
@@ -295,26 +484,14 @@ describe('useErrorOverlayState', () => {
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
it('uses grouped error counts for aggregate copy', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
displayMessage: 'First group message',
|
||||
count: 2,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -323,13 +500,31 @@ describe('useErrorOverlayState', () => {
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:CLIPTextEncode',
|
||||
displayTitle: 'Invalid CLIP input',
|
||||
displayMessage: 'Second group message',
|
||||
count: 3,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '2',
|
||||
title: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Second error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
|
||||
@@ -4,11 +4,17 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
type OverlayCopy = { title?: string; message: string }
|
||||
|
||||
function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
@@ -37,27 +43,119 @@ function resolveSingleOverlayCopy(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGroupOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
const message =
|
||||
group.displayMessage ?? group.toastMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function countMissingNodeReferences(groups: MissingPackGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function countSwapNodeReferences(groups: SwapNodeGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function getMissingModelRows(groups: MissingModelGroup[]) {
|
||||
return groups.flatMap((group) => group.models)
|
||||
}
|
||||
|
||||
function getMissingMediaRows(groups: MissingMediaGroup[]) {
|
||||
return groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function hasSingleRowWithAtMostOneReference(
|
||||
rows: Array<{ referencingNodes: readonly unknown[] }>
|
||||
): boolean {
|
||||
const row = rows[0]
|
||||
return (
|
||||
rows.length === 1 && row !== undefined && row.referencingNodes.length <= 1
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayGroupContext {
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
}
|
||||
|
||||
function isSingleLeafGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'execution') {
|
||||
return group.cards.length === 1 && group.cards[0]?.errors.length === 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_node') {
|
||||
return (
|
||||
context.missingPackGroups.length === 1 &&
|
||||
countMissingNodeReferences(context.missingPackGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return (
|
||||
context.swapNodeGroups.length === 1 &&
|
||||
countSwapNodeReferences(context.swapNodeGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingModelRows(context.missingModelGroups)
|
||||
)
|
||||
}
|
||||
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingMediaRows(context.missingMediaGroups)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldUseAggregateCopyForSingleGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'missing_node') {
|
||||
return context.missingPackGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return context.swapNodeGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return getMissingModelRows(context.missingModelGroups).length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_media') {
|
||||
return getMissingMediaRows(context.missingMediaGroups).length > 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
const { isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const {
|
||||
allErrorGroups,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups('')
|
||||
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
const totalErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
@@ -68,25 +166,38 @@ export function useErrorOverlayState() {
|
||||
)
|
||||
)
|
||||
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
const aggregateOverlayCopy = computed<OverlayCopy>(() => ({
|
||||
title: multipleErrorCountLabel.value,
|
||||
message: t('errorOverlay.multipleErrorsMessage')
|
||||
}))
|
||||
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
const overlayCopy = computed<OverlayCopy | undefined>(() => {
|
||||
const groups = allErrorGroups.value
|
||||
if (groups.length === 0) return undefined
|
||||
if (groups.length > 1) return aggregateOverlayCopy.value
|
||||
|
||||
const [group] = groups
|
||||
const context = {
|
||||
missingPackGroups: missingPackGroups.value,
|
||||
missingModelGroups: missingModelGroups.value,
|
||||
missingMediaGroups: missingMediaGroups.value,
|
||||
swapNodeGroups: swapNodeGroups.value
|
||||
}
|
||||
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
if (shouldUseAggregateCopyForSingleGroup(group, context)) {
|
||||
return aggregateOverlayCopy.value
|
||||
}
|
||||
|
||||
if (isSingleLeafGroup(group, context)) {
|
||||
return resolveSingleOverlayCopy(group) ?? resolveGroupOverlayCopy(group)
|
||||
}
|
||||
|
||||
return resolveGroupOverlayCopy(group)
|
||||
})
|
||||
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
const overlayMessage = computed(() => overlayCopy.value?.message ?? '')
|
||||
|
||||
const overlayTitle = computed(() => overlayCopy.value?.title ?? '')
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
|
||||
@@ -57,154 +57,85 @@ function drawFrame(canvas: LGraphCanvas) {
|
||||
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
|
||||
}
|
||||
|
||||
describe('DomWidgets transition grace characterization', () => {
|
||||
describe('DomWidgets positioning', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('applies transition grace for exactly one frame when override exists but is not active', () => {
|
||||
it('positions an active visible widget relative to its owning node', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
|
||||
|
||||
const widget = createWidget('widget-transition', interiorNode, 14)
|
||||
const overrideWidget = createWidget('override-widget', overrideNode, 22)
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-pos', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([110, 224])
|
||||
})
|
||||
|
||||
it('hides a widget whose owning node is in a different graph', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const currentGraph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = createNode(otherGraph, 1, 'host', [100, 200])
|
||||
const widget = createWidget('widget-other-graph', node, 14)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
|
||||
const canvas = createCanvas(currentGraph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an inactive widget', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graph = new LGraph()
|
||||
const node = createNode(graph, 1, 'host', [0, 0])
|
||||
const widget = createWidget('widget-inactive', node, 10)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
widgetState.visible = true
|
||||
widgetState.pos = [321, 654]
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
const canvas = createCanvas(graph)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
global: { stubs: { DomWidget: true } }
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([321, 654])
|
||||
|
||||
drawFrame(canvas)
|
||||
expect(widgetState.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
|
||||
|
||||
const widget = createWidget('widget-override-active', interiorNode, 8)
|
||||
const overrideWidget = createWidget(
|
||||
'override-position-source',
|
||||
overrideNode,
|
||||
18
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(widget.id)
|
||||
|
||||
const widgetState = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!widgetState) throw new Error('Widget state not registered')
|
||||
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(widgetState.visible).toBe(true)
|
||||
expect(widgetState.pos).toEqual([310, 428])
|
||||
})
|
||||
|
||||
it('cleans orphaned transition-grace ids after widget removal', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const graphA = new LGraph()
|
||||
const graphB = new LGraph()
|
||||
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
|
||||
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
|
||||
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
const overrideWidget = createWidget(
|
||||
'shared-override-widget',
|
||||
overrideNode,
|
||||
14
|
||||
)
|
||||
|
||||
domWidgetStore.registerWidget(oldWidget)
|
||||
domWidgetStore.setPositionOverride(oldWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
domWidgetStore.unregisterWidget(oldWidget.id)
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
|
||||
domWidgetStore.registerWidget(replacementWidget)
|
||||
domWidgetStore.setPositionOverride(replacementWidget.id, {
|
||||
node: overrideNode,
|
||||
widget: overrideWidget
|
||||
})
|
||||
domWidgetStore.deactivateWidget(replacementWidget.id)
|
||||
|
||||
const replacementState = domWidgetStore.widgetStates.get(
|
||||
replacementWidget.id
|
||||
)
|
||||
if (!replacementState) throw new Error('Replacement widget missing state')
|
||||
replacementState.visible = true
|
||||
replacementState.pos = [999, 999]
|
||||
|
||||
drawFrame(canvas)
|
||||
|
||||
expect(replacementState.visible).toBe(true)
|
||||
expect(replacementState.pos).toEqual([999, 999])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const overrideTransitionGrace = new Set<string>()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
|
||||
@@ -31,47 +30,16 @@ const updateWidgets = () => {
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
const seenWidgetIds = new Set<string>()
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
seenWidgetIds.add(widget.id)
|
||||
|
||||
// Use position override only when the override node (SubgraphNode) is
|
||||
// in the current graph. When the user enters the subgraph, the override
|
||||
// node is no longer visible — fall back to the widget's own node.
|
||||
// Use graph reference equality (IDs are not unique across graphs).
|
||||
const override = widgetState.positionOverride
|
||||
const useOverride = !!override && currentGraph === override.node.graph
|
||||
const inOverrideTransitionGap =
|
||||
!!override && !useOverride && !widgetState.active
|
||||
const useTransitionGrace =
|
||||
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
|
||||
|
||||
if (useTransitionGrace) {
|
||||
overrideTransitionGrace.add(widget.id)
|
||||
} else if (!inOverrideTransitionGap) {
|
||||
overrideTransitionGrace.delete(widget.id)
|
||||
}
|
||||
|
||||
// Early exit for non-visible widgets.
|
||||
// When a position override is active (widget promoted to SubgraphNode),
|
||||
// the interior widget's `active` flag is false (its node is in the
|
||||
// subgraph, not the current graph) — bypass that check.
|
||||
if (
|
||||
!widget.isVisible() ||
|
||||
(!widgetState.active && !useOverride && !useTransitionGrace)
|
||||
) {
|
||||
if (!widget.isVisible() || !widgetState.active) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// During graph transitions, hold the previous position for one frame
|
||||
// so promoted widgets don't briefly disappear before activation flips.
|
||||
if (useTransitionGrace) continue
|
||||
|
||||
const posNode = useOverride ? override.node : widget.node
|
||||
const posWidget = useOverride ? override.widget : widget
|
||||
const posNode = widget.node
|
||||
|
||||
const isInCorrectGraph = posNode.graph === currentGraph
|
||||
const nodeVisible = lgCanvas.isNodeVisible(posNode)
|
||||
@@ -85,22 +53,16 @@ const updateWidgets = () => {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [
|
||||
posNode.pos[0] + margin,
|
||||
posNode.pos[1] + margin + posWidget.y
|
||||
posNode.pos[1] + margin + widget.y
|
||||
]
|
||||
widgetState.size = [
|
||||
(posWidget.width ?? posNode.width) - margin * 2,
|
||||
(posWidget.computedHeight ?? 50) - margin * 2
|
||||
(widget.width ?? posNode.width) - margin * 2,
|
||||
(widget.computedHeight ?? 50) - margin * 2
|
||||
]
|
||||
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
|
||||
widgetState.readonly = lgCanvas.read_only
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetId of overrideTransitionGrace) {
|
||||
if (!seenWidgetIds.has(widgetId)) {
|
||||
overrideTransitionGrace.delete(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
@pointerdown.capture="forwardPointerDownPanEvent"
|
||||
@pointerup.capture="forwardPointerUpPanEvent"
|
||||
@pointermove.capture="forwardPointerMovePanEvent"
|
||||
@keydown.space="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<LGraphNode
|
||||
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
function createWidgetState(disabled: boolean): DomWidgetState {
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const node = createMockLGraphNode({
|
||||
id: 1,
|
||||
@@ -70,14 +70,10 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
|
||||
value: '',
|
||||
options: {},
|
||||
node,
|
||||
computedDisabled: false
|
||||
computedDisabled: disabled
|
||||
})
|
||||
|
||||
domWidgetStore.registerWidget(widget)
|
||||
domWidgetStore.setPositionOverride(widget.id, {
|
||||
node: createMockLGraphNode({ id: 2 }),
|
||||
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
|
||||
})
|
||||
|
||||
const state = domWidgetStore.widgetStates.get(widget.id)
|
||||
if (!state) throw new Error('Expected registered DomWidgetState')
|
||||
@@ -98,7 +94,7 @@ describe('DomWidget disabled style', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
it('uses disabled style when widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
|
||||
@@ -69,11 +69,7 @@ const updateDomClipping = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
|
||||
const isSelected = selectedNode === ownerNode
|
||||
const isSelected = selectedNode === widgetState.widget.node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -104,10 +100,7 @@ const updateDomClipping = () => {
|
||||
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
|
||||
|
||||
function composeStyle() {
|
||||
const override = widgetState.positionOverride
|
||||
const isDisabled = override
|
||||
? (override.widget.computedDisabled ?? widget.computedDisabled)
|
||||
: widget.computedDisabled
|
||||
const isDisabled = widget.computedDisabled
|
||||
|
||||
style.value = {
|
||||
...positionStyle.value,
|
||||
@@ -167,13 +160,7 @@ onMounted(() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const override = widgetState.positionOverride
|
||||
const overrideInGraph =
|
||||
override && lgCanvas.graph?.getNodeById(override.node.id)
|
||||
const ownerNode = overrideInGraph
|
||||
? override.node
|
||||
: widgetState.widget.node
|
||||
|
||||
const ownerNode = widgetState.widget.node
|
||||
lgCanvas.selectNode(ownerNode)
|
||||
lgCanvas.bringToFront(ownerNode)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
@@ -129,7 +128,7 @@ const hasDirectNodeError = computed(() =>
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
if (!(node instanceof SubgraphNode)) return false
|
||||
return executionErrorStore.isContainerWithInternalError(node)
|
||||
})
|
||||
})
|
||||
|
||||
71
src/components/rightSidePanel/errors/ErrorCardSection.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -34,7 +36,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -49,7 +51,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -59,29 +61,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -96,13 +98,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -115,60 +117,61 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,6 +78,10 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -118,9 +122,6 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -211,7 +212,13 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -326,6 +333,9 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('1')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -404,7 +414,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -414,6 +424,40 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -431,7 +475,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -453,7 +497,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -495,6 +539,12 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -503,6 +553,73 @@ describe('TabErrors.vue', () => {
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('sums the summary hero count across error types', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'Node'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: { input_name: 'model' }
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: { input_name: 'positive' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
|
||||
})
|
||||
|
||||
// 3 validation items + 2 missing media references
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('5')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
@@ -526,7 +643,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -11,49 +11,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -62,7 +75,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -83,7 +96,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -96,7 +109,7 @@
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@@ -129,140 +142,142 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -304,10 +319,10 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
@@ -323,6 +338,7 @@ import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
@@ -356,31 +372,6 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
@@ -412,14 +403,6 @@ function compareExecutionItemListEntry(
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
@@ -452,6 +435,10 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
21
src/components/rightSidePanel/errors/executionItemList.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
|
||||
export function shouldRenderExecutionItemList(cards: ErrorCardData[]): boolean {
|
||||
return (
|
||||
cards.length > 0 &&
|
||||
cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isExecutionItemListGroup(group: ErrorGroup): boolean {
|
||||
return (
|
||||
group.type === 'execution' && shouldRenderExecutionItemList(group.cards)
|
||||
)
|
||||
}
|
||||
@@ -46,10 +46,6 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
|
||||
groupKey: string
|
||||
/** Human-friendly title resolved for UI display. */
|
||||
displayTitle: string
|
||||
count: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,6 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({
|
||||
@@ -334,7 +330,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -793,53 +789,6 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupedErrorMessages', () => {
|
||||
it('returns empty array when no errors', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'err_a', message: 'Error A', details: '' },
|
||||
{ type: 'err_b', message: 'Error B', details: '' }
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(
|
||||
missingGroup!.displayMessage
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingModelGroups', () => {
|
||||
it('returns empty array when no missing models', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
@@ -982,7 +931,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1047,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,23 +16,22 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getExecutionIdByNode,
|
||||
getRootParentNode
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import {
|
||||
countMissingModels,
|
||||
groupMissingModelCandidates
|
||||
} from '@/platform/missingModel/missingModelGrouping'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import {
|
||||
@@ -49,9 +48,6 @@ const PROMPT_CARD_ID = '__prompt__'
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/** Sentinel key for grouping non-asset-supported missing models. */
|
||||
const UNSUPPORTED = Symbol('unsupported')
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
@@ -85,26 +81,17 @@ interface ErrorSearchItem {
|
||||
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
*/
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
|
||||
|
||||
return {
|
||||
title: isParentGroupNode
|
||||
? parentNode?.title || ''
|
||||
: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined,
|
||||
isParentGroupNode
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -152,16 +139,28 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
|
||||
function countExecutionCards(cards: ErrorCardData[]): number {
|
||||
if (shouldRenderExecutionItemList(cards)) {
|
||||
return cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
|
||||
return cards.length
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([rawGroupKey, groupData]) => ({
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.map(([rawGroupKey, groupData]) => {
|
||||
const cards = Array.from(groupData.cards.values()).sort(compareNodeId)
|
||||
return {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
count: countExecutionCards(cards),
|
||||
cards,
|
||||
priority: groupData.priority
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.displayTitle.localeCompare(b.displayTitle)
|
||||
@@ -220,11 +219,13 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
return groups
|
||||
.map((group, gi) => {
|
||||
if (group.type !== 'execution') return group
|
||||
const cards = group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
return {
|
||||
...group,
|
||||
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
cards,
|
||||
count: countExecutionCards(cards)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
||||
@@ -247,10 +248,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (
|
||||
(item instanceof SubgraphNode || isGroupNode(item)) &&
|
||||
app.rootGraph
|
||||
) {
|
||||
if (item instanceof SubgraphNode && app.rootGraph) {
|
||||
const execId = getExecutionIdByNode(app.rootGraph, item)
|
||||
if (execId) containerExecutionIds.add(execId)
|
||||
}
|
||||
@@ -591,6 +589,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
groupKey: 'swap_nodes',
|
||||
count: swapNodeGroups.value.length,
|
||||
priority: 0,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
@@ -605,6 +604,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
groupKey: 'missing_node',
|
||||
count: missingPackGroups.value.length,
|
||||
priority: 1,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
@@ -618,60 +618,21 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
|
||||
* Within each group, candidates with the same model name are merged into a single view model. */
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
|
||||
type GroupKey = string | null | typeof UNSUPPORTED
|
||||
const map = new Map<
|
||||
GroupKey,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
|
||||
for (const c of candidates) {
|
||||
const groupKey: GroupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
// All candidates in the same directory share the same isAssetSupported
|
||||
// value in practice (a directory is either asset-supported or not).
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
return groupMissingModelCandidates(
|
||||
missingModelStore.missingModelCandidates,
|
||||
isCloud
|
||||
)
|
||||
})
|
||||
|
||||
function buildMissingModelGroups(): ErrorGroup[] {
|
||||
if (!missingModelGroups.value.length) return []
|
||||
const count = missingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
const count = countMissingModels(missingModelGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -696,6 +657,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -737,37 +699,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
|
||||
const map = new Map<
|
||||
string | null | typeof UNSUPPORTED,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
for (const c of filtered) {
|
||||
const groupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
@@ -783,14 +715,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
const count = filteredMissingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
const count = countMissingModels(filteredMissingModelGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -811,6 +741,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -865,22 +796,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.displayMessage ?? err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.add(group.displayMessage ?? group.displayTitle)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -889,7 +804,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
|
||||
@@ -12,18 +12,18 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -146,16 +146,17 @@ function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
if (source) {
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
? source.nodeId
|
||||
: String(widgetNode.id)
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
@@ -190,9 +191,7 @@ const hasDirectError = computed(() => {
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
const isContainer =
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
if (!(targetNode.value instanceof SubgraphNode)) return false
|
||||
|
||||
return executionErrorStore.isContainerWithInternalError(targetNode.value)
|
||||
})
|
||||
@@ -234,7 +233,10 @@ function navigateToErrorTab() {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
|
||||
// are addressed by widgetId; writing there keeps the displayed value in sync.
|
||||
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
@@ -245,18 +247,18 @@ function handleResetAllWidgets() {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
setWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
writeWidgetValue(widget, newValue)
|
||||
setWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
|
||||
})
|
||||
|
||||
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
rows: []
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}[]
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostWithPromotedSeed(): {
|
||||
host: SubgraphNode
|
||||
sourceNode: LGraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const graph = host.graph as LGraph
|
||||
graph.add(host)
|
||||
|
||||
const sourceNode = new LGraphNode('Sampler')
|
||||
const input = sourceNode.addInput('seed', 'INT')
|
||||
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
|
||||
input.widget = { name: seedWidget.name }
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
|
||||
return { host, sourceNode }
|
||||
}
|
||||
|
||||
function renderPanel(node: SubgraphNode) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SectionWidgets: SectionWidgetsStub,
|
||||
AsyncSearchInput: true,
|
||||
CollapseToggleButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
captured.rows = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow).toBeDefined()
|
||||
expect(seedRow?.node.id).toBe(host.id)
|
||||
expect(seedRow?.widget.type).toBe('number')
|
||||
expect(seedRow?.widget.widgetId).toBe(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
)
|
||||
expect(seedRow?.widget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the current host widget value from the store', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
const id = widgetId(host.rootGraph.id, host.id, 'seed')
|
||||
useWidgetValueStore().setValue(id, 7)
|
||||
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
|
||||
expect(seedRow?.widget.value).toBe(7)
|
||||
})
|
||||
|
||||
it('reflects value changes through the same descriptor without rebuilding it', () => {
|
||||
const { host } = buildHostWithPromotedSeed()
|
||||
renderPanel(host)
|
||||
|
||||
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
|
||||
expect(seedRow.widget.value).toBe(42)
|
||||
|
||||
// A value edit must not require a new descriptor object: the same row
|
||||
// reflects the store change via its live getter, keeping render keys stable.
|
||||
useWidgetValueStore().setValue(
|
||||
widgetId(host.rootGraph.id, host.id, 'seed'),
|
||||
100
|
||||
)
|
||||
expect(seedRow.widget.value).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
|
||||
})
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(a) &&
|
||||
isPromotedWidgetView(b) &&
|
||||
a.sourceNodeId === b.sourceNodeId &&
|
||||
a.sourceWidgetName === b.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotedWidgets(): IBaseWidget[] {
|
||||
const inputWidgets = node.inputs
|
||||
.map((input) => input._widget)
|
||||
.filter((widget): widget is IBaseWidget =>
|
||||
Boolean(widget && isPromotedWidgetView(widget))
|
||||
)
|
||||
const extraWidgets = (node.widgets ?? []).filter(
|
||||
(widget) =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
!inputWidgets.some((inputWidget) =>
|
||||
isSamePromotedWidget(inputWidget, widget)
|
||||
)
|
||||
)
|
||||
|
||||
return [...inputWidgets, ...extraWidgets]
|
||||
}
|
||||
|
||||
watch(
|
||||
focusedSection,
|
||||
async (section) => {
|
||||
@@ -93,7 +66,7 @@ watch(
|
||||
)
|
||||
|
||||
const widgetsList = computed((): NodeWidgetsList => {
|
||||
return getPromotedWidgets().map((widget) => ({ node, widget }))
|
||||
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
|
||||
})
|
||||
|
||||
const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -45,8 +47,10 @@ const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const currentValue = computed(
|
||||
() =>
|
||||
(widget.widgetId &&
|
||||
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
|
||||
widget.value
|
||||
)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
return isEqual(currentValue.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
@@ -77,21 +88,15 @@ async function handleRename() {
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
: String(node.id)
|
||||
demoteWidget(
|
||||
{
|
||||
id: sourceNodeId,
|
||||
title: node.title,
|
||||
type: node.type
|
||||
},
|
||||
widget,
|
||||
[parent]
|
||||
)
|
||||
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
|
||||
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock PromotedWidgetView that mirrors the real class:
|
||||
* properties like name, type, value, options are prototype getters,
|
||||
* NOT own properties — so object spread loses them.
|
||||
*/
|
||||
function createMockPromotedWidgetView(
|
||||
sourceOptions: IBaseWidget['options'] = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
): IBaseWidget {
|
||||
class MockPromotedWidgetView {
|
||||
readonly sourceNodeId = '42'
|
||||
readonly sourceWidgetName = 'ckpt_name'
|
||||
readonly serialize = false
|
||||
|
||||
get name(): string {
|
||||
return 'ckpt_name'
|
||||
}
|
||||
get type(): string {
|
||||
return 'combo'
|
||||
}
|
||||
get value(): unknown {
|
||||
return 'model_a.safetensors'
|
||||
}
|
||||
get options(): IBaseWidget['options'] {
|
||||
return sourceOptions
|
||||
}
|
||||
get label(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
get y(): number {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
|
||||
}
|
||||
|
||||
function renderWidgetItem(
|
||||
widget: IBaseWidget,
|
||||
node: LGraphNode = createMockNode()
|
||||
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('promoted widget options', () => {
|
||||
describe('widget state rendering', () => {
|
||||
it('passes options from a regular widget to the widget component', () => {
|
||||
const widget = createMockWidget({
|
||||
options: { values: ['a', 'b', 'c'] }
|
||||
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes options from a PromotedWidgetView to the widget component', () => {
|
||||
it('passes options from widget state to the widget component', () => {
|
||||
const expectedOptions = {
|
||||
values: ['model_a.safetensors', 'model_b.safetensors']
|
||||
}
|
||||
const widget = createMockPromotedWidgetView(expectedOptions)
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: expectedOptions
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.options).toEqual(expectedOptions)
|
||||
})
|
||||
|
||||
it('passes type from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes type from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, type: 'string' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.type).toBe('combo')
|
||||
})
|
||||
|
||||
it('passes name from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes name from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
expect(stub.name).toBe('ckpt_name')
|
||||
})
|
||||
|
||||
it('passes value from a PromotedWidgetView to the widget component', () => {
|
||||
const widget = createMockPromotedWidgetView()
|
||||
it('passes value from widget state to the widget component', () => {
|
||||
const id = widgetId('test-graph-id', 1, 'ckpt_name')
|
||||
const widget = createMockWidget({ widgetId: id, value: 'source value' })
|
||||
useWidgetValueStore().registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'model_a.safetensors',
|
||||
options: { values: ['model_a.safetensors'] }
|
||||
})
|
||||
|
||||
const { container } = renderWidgetItem(widget)
|
||||
const stub = getStubWidget(container)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
@@ -67,35 +68,43 @@ const widgetComponent = computed(() => {
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const source = resolvePromotedWidgetSource(node, widget)
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
const isLinked = computed(() => {
|
||||
const safeWidget = useVueNodeLifecycle()
|
||||
.nodeManager.value?.vueNodeData.get(String(node.id))
|
||||
?.widgets?.find((w) => w.name === widget.name)
|
||||
return safeWidget?.slotMetadata
|
||||
? !!safeWidget.slotMetadata.linked
|
||||
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
|
||||
})
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
const bareNodeId = stripGraphPrefix(String(node.id))
|
||||
const widgetState = widget.widgetId
|
||||
? useWidgetValueStore().getWidget(widget.widgetId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
|
||||
: undefined
|
||||
const widgetName = widgetState?.name ?? widget.name
|
||||
const widgetType = widgetState?.type ?? widget.type
|
||||
|
||||
const baseOptions = widgetState?.options ?? widget.options
|
||||
const disabled = isLinked.value || !!widget.disabled || undefined
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
name: widgetName,
|
||||
type: widgetType,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
options: { ...baseOptions, disabled },
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
|
||||
controlWidget: getControlWidget(widget)
|
||||
}
|
||||
})
|
||||
|
||||
const sourceNodeName = computed((): string | null => {
|
||||
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
|
||||
if (!sourceNode) return null
|
||||
const displayNodeName = computed((): string | null => {
|
||||
if (!node) return null
|
||||
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
||||
return resolveNodeDisplayName(sourceNode, {
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallbackNodeTitle,
|
||||
untitledLabel: fallbackNodeTitle,
|
||||
st
|
||||
@@ -167,10 +176,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && sourceNodeName"
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
{{ displayNodeName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
|
||||
@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
const rowFor = (sourceNode: LGraphNode) => {
|
||||
const input = host.inputs.find((input) => {
|
||||
if (!input.widgetId) return false
|
||||
const target = resolveSubgraphInputTarget(host, input.name)
|
||||
return target?.nodeId === String(sourceNode.id)
|
||||
})!
|
||||
return {
|
||||
kind: 'promoted',
|
||||
node: sourceNode,
|
||||
input,
|
||||
widget: promotedInputWidget(input)!
|
||||
}
|
||||
}
|
||||
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
@@ -182,6 +192,42 @@ describe('SubgraphEditor', () => {
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('moves a widget to shown when promoted from the hidden section', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const sourceInput = sourceNode.addInput('first', 'STRING')
|
||||
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
|
||||
sourceInput.widget = { name: sourceWidget.name }
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
|
||||
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -213,13 +259,13 @@ describe('SubgraphEditor', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
|
||||
@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
@@ -16,8 +15,14 @@ import {
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
promotedInputSource,
|
||||
promotedInputWidget
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
widget: PromotedWidgetView
|
||||
input: INodeInputSlot
|
||||
widget: IBaseWidget
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
const promotedRows = shallowRef<readonly PromotedRow[]>([])
|
||||
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return node.inputs.flatMap((input): PromotedRow[] => {
|
||||
const widget = promotedInputWidget(input)
|
||||
if (!widget) return []
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, input, widget }]
|
||||
})
|
||||
}
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
function refreshPromotedRows() {
|
||||
const node = activeNode.value
|
||||
promotedRows.value = node ? buildPromotedRows(node) : []
|
||||
}
|
||||
watch(activeNode, refreshPromotedRows, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
@@ -68,34 +86,29 @@ useEventListener(
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedWidgets
|
||||
refreshPromotedRows
|
||||
)
|
||||
|
||||
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
|
||||
const node = activeNode.value
|
||||
return node ? promotedInputSource(node, row.input) : undefined
|
||||
}
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
return [...promotedRows.value, ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedRows(node) : []
|
||||
return [...promotedRows.value]
|
||||
},
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => row.widget)
|
||||
value.map((row) => ({ widgetId: row.widget.widgetId }))
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
if (row.kind !== 'promoted')
|
||||
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
|
||||
const source = promotedRowSource(row)
|
||||
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
return `${row.node.id}: ${row.widget.name}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
@@ -274,13 +294,18 @@ function demoteRow(row: ActiveRow) {
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
const source = promotedRowSource(row)
|
||||
const sourceWidget = source
|
||||
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
|
||||
: undefined
|
||||
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
refreshPromotedRows()
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
|
||||
@@ -7,11 +7,7 @@ import type {
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
@@ -44,15 +40,6 @@ export function useFocusNode() {
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
// For group node internals, locate the root parent group node instead
|
||||
const parentNode = getRootParentNode(app.rootGraph, nodeId)
|
||||
|
||||
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
|
||||
await navigateToGraph(parentNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
|
||||
return
|
||||
}
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
@@ -4,12 +4,8 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -256,273 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('uses interior node execution ID for promoted widget error clearing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['model.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'other_model.safetensors',
|
||||
'model.safetensors',
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
@@ -1249,4 +978,54 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears promoted widget errors by interior execution id', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const graph = subgraph.rootGraph
|
||||
const host = createTestSubgraphNode(subgraph, { id: 2 })
|
||||
graph.add(host)
|
||||
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.id = 1
|
||||
subgraph.add(interiorNode)
|
||||
const input = interiorNode.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'fake_model.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
|
||||
)
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
|
||||
).toBe(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const missingModelStore = useMissingModelStore()
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: '2:1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const promotedWidget = host.widgets[0]
|
||||
host.onWidgetChanged!.call(
|
||||
host,
|
||||
promotedWidget.name,
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
promotedWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.hasMissingModels).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
* works in legacy canvas mode as well.
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
@@ -46,130 +43,6 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
|
||||
type OriginalCallbacks = {
|
||||
@@ -203,21 +76,24 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
node.onWidgetChanged = useChainCallback(
|
||||
node.onWidgetChanged,
|
||||
// _name is the LiteGraph callback arg; re-derive from the widget
|
||||
// object to handle promoted widgets where sourceWidgetName differs.
|
||||
function (_name, newValue, _oldValue, widget) {
|
||||
if (!app.rootGraph) return
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
executionId,
|
||||
widgetName,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
const id = widgetId(graph.id, node.id, 'testnum')
|
||||
expect(store.getWidget(id)?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(id)
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
|
||||
// Subgraph input named "value" promotes an interior "prompt" widget. The
|
||||
// projected widget's name is the input slot name "value"; the interior
|
||||
// source widget name "prompt" is carried separately for backend lookups.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
interiorInput.widget = { name: 'prompt' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
'value'
|
||||
)
|
||||
|
||||
// Host the promoted view on a regular node so we can control widgets
|
||||
// directly (SubgraphNode.widgets is a synthetic getter).
|
||||
const graph = new LGraph()
|
||||
const hostNode = new LGraphNode('host')
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceNodeId = '9999'
|
||||
fromAny<
|
||||
{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
},
|
||||
unknown
|
||||
>(secondPromotedView).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.sourceWidgetName).toBe('prompt')
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
expect(mappedWidget?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
expect(widgets?.[0]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
expect(widgets?.[1]?.widgetId).toBe(
|
||||
widgetId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_name'
|
||||
(w) => w.name === 'ckpt_input'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
|
||||
// The interior node is inside subgraphNode (id=65),
|
||||
// so its execution ID should be "65:<interiorNodeId>"
|
||||
expect(promotedWidget?.sourceExecutionId).toBe(
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidgets
|
||||
} from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -27,10 +26,11 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -38,7 +38,8 @@ import type {
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -60,7 +61,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
entityId?: WidgetEntityId
|
||||
widgetId?: WidgetId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
@@ -81,17 +82,12 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
@@ -99,10 +95,14 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
* widget name, which can differ from the slot name (e.g. after a rename).
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -143,18 +143,6 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
if (!isPromotedWidgetView(widget)) return false
|
||||
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
|
||||
if (!sourceWidget) return false
|
||||
|
||||
const innerWidget = sourceWidget.widget
|
||||
return (
|
||||
('element' in innerWidget && !!innerWidget.element) ||
|
||||
('component' in innerWidget && !!innerWidget.component)
|
||||
)
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
@@ -214,73 +202,83 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input?.widgetId) return undefined
|
||||
const source = promotedInputSource(node, input)
|
||||
if (!source) return undefined
|
||||
|
||||
const resolution = resolveConcretePromotedWidget(
|
||||
node,
|
||||
source.nodeId,
|
||||
source.widgetName
|
||||
)
|
||||
const resolved =
|
||||
resolution.status === 'resolved' ? resolution.resolved : undefined
|
||||
const sourceWidget = resolved?.widget
|
||||
const sourceNode = resolved?.node
|
||||
|
||||
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
|
||||
|
||||
return {
|
||||
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
|
||||
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
sourceWidgetName: sourceWidget?.name
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
|
||||
displayName: string
|
||||
promotedSource: PromotedWidgetSource | null
|
||||
} {
|
||||
if (!isPromotedWidgetView(widget)) {
|
||||
return {
|
||||
displayName: widget.name,
|
||||
promotedSource: null
|
||||
}
|
||||
}
|
||||
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
promotedSource
|
||||
}
|
||||
}
|
||||
const duplicateIndexByKey = new Map<string, number>()
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
const duplicateKey = `${widget.name}:${widget.type}`
|
||||
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
|
||||
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
@@ -294,67 +292,26 @@ function safeWidgetMapper(
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const resolvedSourceResult =
|
||||
isPromotedWidgetView(widget) && promotedSource
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
resolvedSourceResult?.status === 'resolved'
|
||||
? resolvedSourceResult.resolved
|
||||
: undefined
|
||||
const sourceWidget = resolvedSource?.widget
|
||||
const sourceNode = resolvedSource?.node
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
name,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
callback,
|
||||
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
|
||||
options: isPromotedPseudoWidget
|
||||
? {
|
||||
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
canvasOnly: true
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
slotMetadata: slotInfo,
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -370,6 +327,24 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
@@ -471,14 +446,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const widgetsSnapshot = node.widgets ?? []
|
||||
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
@@ -534,7 +511,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +789,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||