Compare commits
54 Commits
feat/nativ
...
unify-webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13129d27cd | ||
|
|
27efdc695e | ||
|
|
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 | ||
|
|
5535e93ef3 | ||
|
|
4b979f4ad0 | ||
|
|
700ff4644f | ||
|
|
e832380c33 | ||
|
|
6d43320b93 | ||
|
|
99a2320a42 | ||
|
|
2b0bcda41f | ||
|
|
b9112f9bd7 | ||
|
|
b5b124fa9e | ||
|
|
e138d17459 | ||
|
|
f212c7d409 | ||
|
|
1d5801d6ef | ||
|
|
193f23e8c2 | ||
|
|
eaa6776559 | ||
|
|
afd42525fe | ||
|
|
0c392e53a2 | ||
|
|
46526cfabd | ||
|
|
fefbe7843c | ||
|
|
6445690ed3 | ||
|
|
603914e78f | ||
|
|
c7797b201e | ||
|
|
aa68573a6e | ||
|
|
79acf7be5e |
@@ -1,5 +1,5 @@
|
||||
name: Post Release Summary Comment
|
||||
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
|
||||
description: Upsert a release-links section (diff, derived versions, optional extras) into the unified PR report comment.
|
||||
author: ComfyUI Frontend Team
|
||||
|
||||
inputs:
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKER='release-summary'
|
||||
SECTION_NAME='release'
|
||||
MESSAGE='Publish jobs finished successfully:'
|
||||
LINKS_VALUE=''
|
||||
|
||||
@@ -60,7 +60,7 @@ runs:
|
||||
'npm types|https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/{{version}}')
|
||||
;;
|
||||
apps/desktop-ui/package.json)
|
||||
MARKER='desktop-release-summary'
|
||||
SECTION_NAME='desktop-release'
|
||||
LINKS_VALUE='npm desktop UI|https://www.npmjs.com/package/@comfyorg/desktop-ui/v/{{version}}'
|
||||
;;
|
||||
esac
|
||||
@@ -71,7 +71,6 @@ runs:
|
||||
COMMENT_FILE=$(mktemp)
|
||||
|
||||
{
|
||||
echo "<!--$MARKER:$DIFF_PREFIX$NEW_VERSION-->"
|
||||
echo "$MESSAGE"
|
||||
echo ""
|
||||
echo "- $DIFF_LABEL: [\`$DIFF_PREFIX$PREV_VERSION...$DIFF_PREFIX$NEW_VERSION\`]($DIFF_URL)"
|
||||
@@ -99,21 +98,14 @@ runs:
|
||||
echo "COMMENT_BODY_END_MARKER"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
|
||||
echo "section_name=$SECTION_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find existing comment
|
||||
id: find
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
- name: Upsert release section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-author: github-actions[bot]
|
||||
body-includes: ${{ steps.build.outputs.marker_search }}
|
||||
|
||||
- name: Post or update comment
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find.outputs.comment-id }}
|
||||
body: ${{ steps.build.outputs.body }}
|
||||
edit-mode: replace
|
||||
pr-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
section-name: ${{ steps.build.outputs.section_name }}
|
||||
section-content: ${{ steps.build.outputs.body }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
44
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -224,7 +224,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 +240,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 +273,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 }}
|
||||
|
||||
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -185,7 +185,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🌐 Website E2E
|
||||
@@ -265,6 +265,6 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
|
||||
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 }}
|
||||
17
.github/workflows/pr-report.yaml
vendored
@@ -140,6 +140,9 @@ jobs:
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
'<!-- PLAYWRIGHT_TEST_STATUS -->',
|
||||
'<!-- STORYBOOK_BUILD_STATUS -->',
|
||||
'<!-- WEBSITE_CI_REPORT -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
@@ -160,11 +163,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 }}
|
||||
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.number || github.event.issue.number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 📸 Screenshot Update
|
||||
@@ -240,6 +240,6 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
section-name: preview
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🔗 Website Preview
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unreachable": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
"no-useless-rename": "off",
|
||||
@@ -73,12 +74,14 @@
|
||||
"import/namespace": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
|
||||
"jest/expect-expect": "off",
|
||||
"jest/no-conditional-expect": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"jest/no-standalone-expect": "off",
|
||||
"jest/valid-title": "off",
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/no-conditional-expect": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"vitest/no-standalone-expect": "off",
|
||||
"vitest/valid-title": "off",
|
||||
"vitest/require-to-throw-message": "off",
|
||||
"typescript/no-this-alias": "off",
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"typescript/no-unnecessary-parameter-property-assignment": "off",
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
|
||||
@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "Comfy",
|
||||
"short_name": "Comfy",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#211927",
|
||||
"background_color": "#211927",
|
||||
"display": "standalone"
|
||||
"display": "standalone",
|
||||
"id": "/",
|
||||
"start_url": "/"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Local', href: '/local' },
|
||||
{ label: 'Desktop', href: '/download' },
|
||||
{ label: 'Cloud', href: '/cloud' },
|
||||
{ label: 'API', href: '/api' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
|
||||
@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
title: 'Comfy\nLocal',
|
||||
title: 'Comfy\nDesktop',
|
||||
description: 'Run ComfyUI on your own hardware.',
|
||||
cta: 'SEE LOCAL FEATURES',
|
||||
cta: 'SEE DESKTOP FEATURES',
|
||||
href: '#',
|
||||
bg: 'bg-primary-warm-gray'
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export const AllCards: Story = {
|
||||
template: `
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
title="Comfy\nLocal"
|
||||
title="Comfy\nDesktop"
|
||||
description="Run ComfyUI on your own hardware."
|
||||
cta="SEE LOCAL FEATURES"
|
||||
cta="SEE DESKTOP FEATURES"
|
||||
href="#"
|
||||
bg="bg-primary-warm-gray"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Platform } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
@@ -18,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const ICONS = {
|
||||
const label = computed(() => t('download.hero.downloadLocal', locale))
|
||||
|
||||
const ICONS: Record<Platform, string> = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
}
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
key: Platform
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
@@ -41,19 +44,18 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
ariaLabel: `${label.value} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
ariaLabel: `${label.value} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,11 +79,8 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="ppformula-text-center">{{
|
||||
t('download.hero.downloadLocal', locale)
|
||||
}}</span>
|
||||
<span class="ppformula-text-center">{{ label }}</span>
|
||||
</span>
|
||||
</BrandButton>
|
||||
</template>
|
||||
|
||||
@@ -7,13 +7,13 @@ export const downloadUrls = {
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
type DetectedPlatform = 'windows' | 'mac' | null
|
||||
export type Platform = 'windows' | 'mac'
|
||||
|
||||
function isMobile(ua: string): boolean {
|
||||
return /iphone|ipad|ipod|android/.test(ua)
|
||||
}
|
||||
|
||||
function detectPlatform(ua: string): DetectedPlatform {
|
||||
function detectPlatform(ua: string): Platform | null {
|
||||
if (isMobile(ua)) return null
|
||||
if (ua.includes('win')) return 'windows'
|
||||
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
|
||||
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// TODO: Only Windows x64 and macOS arm64 are available today.
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const platform = ref<Platform | null>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
|
||||
@@ -174,16 +174,16 @@ const translations = {
|
||||
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
|
||||
},
|
||||
'products.local.title': {
|
||||
en: 'Comfy\nLocal',
|
||||
'zh-CN': 'Comfy\n本地版'
|
||||
en: 'Comfy\nDesktop',
|
||||
'zh-CN': 'Comfy\n桌面版'
|
||||
},
|
||||
'products.local.description': {
|
||||
en: 'Run ComfyUI on your own hardware.',
|
||||
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
en: 'SEE DESKTOP FEATURES',
|
||||
'zh-CN': '查看桌面版属性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -1057,18 +1057,18 @@ const translations = {
|
||||
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
|
||||
},
|
||||
'cloud.faq.2.a': {
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
|
||||
'zh-CN':
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
},
|
||||
'cloud.faq.3.q': {
|
||||
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版?'
|
||||
},
|
||||
'cloud.faq.3.a': {
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
},
|
||||
'cloud.faq.4.q': {
|
||||
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
|
||||
@@ -1091,9 +1091,9 @@ const translations = {
|
||||
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
|
||||
},
|
||||
'cloud.faq.6.a': {
|
||||
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
'zh-CN':
|
||||
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
},
|
||||
'cloud.faq.7.q': {
|
||||
en: 'Are all ComfyUI extensions and custom nodes supported?',
|
||||
@@ -1145,9 +1145,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
|
||||
},
|
||||
'cloud.faq.12.a': {
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
},
|
||||
'cloud.faq.13.q': {
|
||||
en: 'Can I cancel my subscription?',
|
||||
@@ -1385,9 +1385,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',
|
||||
@@ -1411,9 +1411,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -73,7 +73,7 @@ const websiteJsonLd = {
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#211927" />
|
||||
|
||||
@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import type { Platform } from '@/composables/useDownloadUrl'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -39,7 +41,7 @@ export function capturePageview() {
|
||||
}
|
||||
}
|
||||
|
||||
export function captureDownloadClick(platform: string) {
|
||||
export function captureDownloadClick(platform: Platform) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:download_button_clicked', { platform })
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["cloud_importable_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [560, 100],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "cloud_importable_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
61
browser_tests/assets/missing/node_replacement_same_type.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [520, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [43, 20, 7, "euler", "normal"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -60,16 +60,19 @@ export const TestIds = {
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
missingModelImport: 'missing-model-import',
|
||||
missingModelImportableRows: 'missing-model-importable-rows',
|
||||
missingModelLocate: 'missing-model-locate',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelReferenceCount: 'missing-model-reference-count',
|
||||
missingModelUnsupportedSection:
|
||||
'missing-model-import-not-supported-section',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingModelRefresh: 'missing-model-header-refresh',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
swapNodeGroupCount: 'swap-node-group-count',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
@@ -136,7 +139,8 @@ export const TestIds = {
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
convertSubgraph: 'convert-to-subgraph-button',
|
||||
bypass: 'bypass-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -18,87 +17,245 @@ test.describe('Background Image Upload', () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
|
||||
})
|
||||
|
||||
async function openBackgroundImageSetting(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.locator('text=Appearance').click()
|
||||
return comfyPage.page.locator('#Comfy\\.Canvas\\.BackgroundImage')
|
||||
}
|
||||
|
||||
test('should show background image upload component in settings', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
await expect(backgroundImageSetting).toBeVisible()
|
||||
|
||||
// With no image set: placeholder shown, no remove button
|
||||
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeHidden()
|
||||
// Verify the component has the expected elements using semantic selectors
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await expect(urlInput).toBeVisible()
|
||||
await expect(urlInput).toHaveAttribute('placeholder')
|
||||
|
||||
const uploadButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
await expect(uploadButton).toBeVisible()
|
||||
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeDisabled() // Should be disabled when no image
|
||||
})
|
||||
|
||||
test('should upload image file and set as background', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Clicking the row opens the system file browser
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
// Click the upload button to trigger file input
|
||||
const uploadButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
|
||||
// Set up file upload handler
|
||||
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
|
||||
await backgroundImageSetting
|
||||
.getByRole('button', { name: 'Choose image' })
|
||||
.click()
|
||||
await uploadButton.click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
|
||||
// Upload the test image
|
||||
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
|
||||
|
||||
// The row shows the uploaded file's base name and a remove button
|
||||
await expect(
|
||||
backgroundImageSetting.getByText('image32x32.webp')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeVisible()
|
||||
// Verify the URL input now has an API URL
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
|
||||
// The setting value points at the uploaded file
|
||||
// Verify clear button is now enabled
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Verify the setting value was actually set
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Canvas.BackgroundImage'
|
||||
)
|
||||
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
})
|
||||
|
||||
test('should show the base name of an existing background image', async ({
|
||||
test('should accept URL input for background image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.BackgroundImage',
|
||||
'/api/view?filename=backgrounds%2Ftest-image.png&type=input&subfolder=backgrounds'
|
||||
)
|
||||
const testImageUrl = 'https://example.com/test-image.png'
|
||||
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
await expect(
|
||||
backgroundImageSetting.getByText('test-image.png')
|
||||
).toBeVisible()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
// Enter URL in the input field
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await urlInput.fill(testImageUrl)
|
||||
|
||||
// Trigger blur event to ensure the value is set
|
||||
await urlInput.blur()
|
||||
|
||||
// Verify clear button is now enabled
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Verify the setting value was updated
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Canvas.BackgroundImage'
|
||||
)
|
||||
expect(settingValue).toBe(testImageUrl)
|
||||
})
|
||||
|
||||
test('should clear background image with the remove button', async ({
|
||||
test('should clear background image when clear button is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const testImageUrl = 'https://example.com/test-image.png'
|
||||
|
||||
// First set a background image
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.BackgroundImage',
|
||||
'/api/view?filename=test-image.png&type=input'
|
||||
testImageUrl
|
||||
)
|
||||
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
await backgroundImageSetting
|
||||
.getByRole('button', { name: 'Remove image' })
|
||||
.click()
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Placeholder returns, remove button disappears, setting cleared
|
||||
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.Canvas.BackgroundImage'))
|
||||
.toBe('')
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
// Verify the input has the test URL
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await expect(urlInput).toHaveValue(testImageUrl)
|
||||
|
||||
// Verify clear button is enabled
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Click the clear button
|
||||
await clearButton.click()
|
||||
|
||||
// Verify the input is now empty
|
||||
await expect(urlInput).toHaveValue('')
|
||||
|
||||
// Verify clear button is now disabled
|
||||
await expect(clearButton).toBeDisabled()
|
||||
|
||||
// Verify the setting value was cleared
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Canvas.BackgroundImage'
|
||||
)
|
||||
expect(settingValue).toBe('')
|
||||
})
|
||||
|
||||
test('should show tooltip on upload and clear buttons', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
// Hover over upload button and verify tooltip appears
|
||||
const uploadButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
await uploadButton.hover()
|
||||
|
||||
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
|
||||
await expect(uploadTooltip).toBeVisible()
|
||||
|
||||
// Move away to hide tooltip
|
||||
await comfyPage.page.locator('body').hover()
|
||||
|
||||
// Set a background to enable clear button
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await urlInput.fill('https://example.com/test.png')
|
||||
await urlInput.blur()
|
||||
|
||||
// Hover over clear button and verify tooltip appears
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await clearButton.hover()
|
||||
|
||||
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
|
||||
await expect(clearTooltip).toBeVisible()
|
||||
})
|
||||
|
||||
test('should maintain reactive updates between URL input and clear button state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
|
||||
// Navigate to Appearance category
|
||||
const appearanceOption = comfyPage.page.locator('text=Appearance')
|
||||
await appearanceOption.click()
|
||||
|
||||
// Find the background image setting
|
||||
const backgroundImageSetting = comfyPage.page.locator(
|
||||
'#Comfy\\.Canvas\\.BackgroundImage'
|
||||
)
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
|
||||
// Initially clear button should be disabled
|
||||
await expect(clearButton).toBeDisabled()
|
||||
|
||||
// Type some text - clear button should become enabled
|
||||
await urlInput.fill('test')
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Clear the text manually - clear button should become disabled again
|
||||
await urlInput.fill('')
|
||||
await expect(clearButton).toBeDisabled()
|
||||
|
||||
// Add text again - clear button should become enabled
|
||||
await urlInput.fill('https://example.com/image.png')
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Use clear button - should clear input and disable itself
|
||||
await clearButton.click()
|
||||
await expect(urlInput).toHaveValue('')
|
||||
await expect(clearButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,6 +190,16 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
'custom-color-palette-obsidian-dark.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Palette can modify @vue-nodes color', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const getColor = () =>
|
||||
node.body.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
|
||||
const initialColor = await getColor()
|
||||
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'solarized')
|
||||
await expect.poll(getColor).not.toEqual(initialColor)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
|
||||
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 321 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 |
@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows direct row label and locate action for a single replacement group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
const rowLabel = swapGroup.getByRole('button', {
|
||||
name: 'E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(rowLabel).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByRole('button', {
|
||||
name: 'Locate node on canvas',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
|
||||
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
|
||||
|
||||
await rowLabel.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getOffset())
|
||||
.not.toEqual(offsetBeforeLocate)
|
||||
})
|
||||
|
||||
test('Replace Node replaces a single group in-place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -101,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 ({
|
||||
@@ -116,6 +154,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Same-type replacement group', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_same_type'
|
||||
)
|
||||
})
|
||||
|
||||
test('Groups same-type replacement rows behind the title disclosure', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
const countBadge = swapGroup.getByTestId(
|
||||
TestIds.dialogs.swapNodeGroupCount
|
||||
)
|
||||
const childRows = swapGroup.getByRole('listitem')
|
||||
const expandButton = swapGroup.getByRole('button', {
|
||||
name: 'Expand E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(expandButton).toBeVisible()
|
||||
await expect(countBadge).toHaveText('2')
|
||||
await expect(childRows).toHaveCount(0)
|
||||
|
||||
await expandButton.click()
|
||||
await expect(childRows).toHaveCount(2)
|
||||
await expect(
|
||||
swapGroup.getByRole('button', {
|
||||
name: 'E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
).toHaveCount(2)
|
||||
|
||||
await swapGroup
|
||||
.getByRole('button', {
|
||||
name: 'Collapse E2E_OldSampler',
|
||||
exact: true
|
||||
})
|
||||
.click()
|
||||
await expect(childRows).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-type replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
|
||||
@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
'models/checkpoints/cloud_importable_model.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-existing-cloud-importable-model',
|
||||
name: 'asset-record-display-name.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
|
||||
size: 2_048,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
new URL(requestUrl).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function filterAssetsByRequest(
|
||||
assets: ReadonlyArray<Asset>,
|
||||
requestUrl: string
|
||||
): Asset[] {
|
||||
const includeTags = getRequestedIncludeTags(requestUrl)
|
||||
return includeTags.length
|
||||
? assets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: [...assets]
|
||||
}
|
||||
|
||||
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
model_upload_button_enabled: true,
|
||||
private_models_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableMissingModelImportFeatures(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -88,5 +150,216 @@ test.describe(
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
|
||||
test('separates importable cloud models from unsupported rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const importableRows = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelImportableRows
|
||||
)
|
||||
const unsupportedSection = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelUnsupportedSection
|
||||
)
|
||||
|
||||
await expect(
|
||||
importableRows.getByRole('button', {
|
||||
name: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(unsupportedSection).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Import Not Supported')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(
|
||||
/Nodes that reference the models below do not support imported models/
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Unknown', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByRole('button', {
|
||||
name: 'Load Image',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('opens cloud import with missing-model replacement context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 1024,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'replacement.safetensors',
|
||||
tags: ['loras']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const urlInput = comfyPage.page.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await expect(urlInput).toBeVisible()
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
|
||||
)
|
||||
await comfyPage.page
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
|
||||
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
/Locked to (Checkpoints|checkpoints) for this missing model/
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('uses the synced asset filename when applying an already imported cloud model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let isImportedAssetAvailable = false
|
||||
const visibleAssets = () =>
|
||||
isImportedAssetAvailable
|
||||
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
|
||||
: [LOTUS_DIFFUSION_MODEL]
|
||||
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
|
||||
const assets = filterAssetsByRequest(
|
||||
visibleAssets(),
|
||||
route.request().url()
|
||||
)
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 2048,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
tags: ['checkpoints']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/download', (route) => {
|
||||
isImportedAssetAvailable = true
|
||||
const response: AssetCreated = {
|
||||
...EXISTING_CLOUD_IMPORTABLE_MODEL,
|
||||
created_new: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
const urlInput = uploadDialog.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
|
||||
)
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step2-confirm-button"]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -11,6 +12,18 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Should display model name and metadata', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
await expect(getModelLabel(modelsGroup)).toBeVisible()
|
||||
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
const expandButton = modelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expectReferenceBadge(modelsGroup, 2)
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
await expect(
|
||||
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
const copyButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
expect(copiedText).toContain('/api/devtools/')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelDownload
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
await expect(downloadButton.first()).toHaveText('Download')
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
|
||||
@@ -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: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -129,23 +129,18 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// A group + a KSampler node
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group')
|
||||
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
|
||||
|
||||
// Select group + node should show bypass button
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+A')
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// Deselect node (Only group is selected) should hide bypass button
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeHidden()
|
||||
await expect(bypass).toBeVisible()
|
||||
await comfyPage.keyboard.delete()
|
||||
|
||||
// (Only empty group is selected) should hide bypass button
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(bypass).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Color Picker', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -292,10 +292,6 @@ test.describe('Node library sidebar', () => {
|
||||
const dialog = comfyPage.page.getByRole('dialog', {
|
||||
name: 'Customize Folder'
|
||||
})
|
||||
// Capture the dialog header position before opening the modal color
|
||||
// picker: while the picker is open it sets aria-hidden on the dialog,
|
||||
// so it can no longer be located by role.
|
||||
const dialogBox = await dialog.boundingBox()
|
||||
await dialog
|
||||
.locator('.color-customization-selector-container > button')
|
||||
.last()
|
||||
@@ -304,17 +300,6 @@ test.describe('Node library sidebar', () => {
|
||||
.getByLabel('Color saturation and brightness')
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
// The color picker popover is modal: while it is open the rest of the
|
||||
// dialog is inert (pointer-events disabled), so dismiss it before
|
||||
// interacting with other controls. A coordinate click on the dialog
|
||||
// header lands on the dismiss layer and closes the popover.
|
||||
if (dialogBox) {
|
||||
await comfyPage.page.mouse.click(dialogBox.x + 40, dialogBox.y + 16)
|
||||
}
|
||||
await expect(
|
||||
comfyPage.page.getByLabel('Color saturation and brightness')
|
||||
).toBeHidden()
|
||||
|
||||
// Select Folder icon (2nd button in Icon group)
|
||||
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
|
||||
await iconGroup.getByRole('button').nth(1).click()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
@@ -175,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
|
||||
}) => {
|
||||
@@ -217,4 +243,40 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('.')
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
|
||||
const toggleBypass = () =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
|
||||
const bypassCount = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.nodes.filter((node) => node.mode === 4).length
|
||||
)
|
||||
expect(await bypassCount()).toBe(0)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.select()
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await ksampler.select()
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,4 +98,43 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
|
||||
})
|
||||
|
||||
test('opens the switcher to the left of the profile menu without overlap', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
const panel = page.getByTestId('workspace-switcher-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
const profileMenu = page.locator('.current-user-popover')
|
||||
const panelBox = await panel.boundingBox()
|
||||
const profileBox = await profileMenu.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(profileBox).not.toBeNull()
|
||||
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
|
||||
})
|
||||
|
||||
test('opens the create-workspace dialog with DES-246 copy', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
await page.getByText('Create a workspace').click()
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.12",
|
||||
"version": "1.47.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -66,6 +66,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:",
|
||||
@@ -206,7 +207,7 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25",
|
||||
"node": ">=25 <26",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
|
||||
@@ -7,7 +7,10 @@ export type { ClassValue } from 'clsx'
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
'font-size': ['text-xxs', 'text-xxxs'],
|
||||
// tailwind-merge does not know Tailwind's `max-h-none`, so it never
|
||||
// resolves conflicts like `max-h-[80vh] max-h-none` (both survive).
|
||||
'max-h': [{ 'max-h': ['none'] }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
572
pnpm-lock.yaml
generated
@@ -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
|
||||
@@ -79,7 +80,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
@@ -101,9 +102,9 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"litegraph_base": {
|
||||
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
|
||||
"CLEAR_BACKGROUND_COLOR": "#222222",
|
||||
"CLEAR_BACKGROUND_COLOR": "#141414",
|
||||
"NODE_TITLE_COLOR": "#999",
|
||||
"NODE_SELECTED_TITLE_COLOR": "#FFF",
|
||||
"NODE_TEXT_COLOR": "#AAA",
|
||||
|
||||
@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: mockTrackUiButtonClicked
|
||||
})
|
||||
}))
|
||||
|
||||
type WrapperOptions = {
|
||||
pinia?: ReturnType<typeof createTestingPinia>
|
||||
stubs?: Record<string, boolean | Component>
|
||||
@@ -110,6 +118,9 @@ function createWrapper({
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
},
|
||||
rightSidePanel: {
|
||||
togglePanel: 'Toggle properties panel'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
|
||||
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks right side panel opens', async () => {
|
||||
const { user } = createWrapper()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Toggle properties panel' })
|
||||
)
|
||||
|
||||
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'right_side_panel_opened',
|
||||
element_group: 'top_menu'
|
||||
})
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
|
||||
function openRightSidePanel() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'right_side_panel_opened',
|
||||
element_group: 'top_menu'
|
||||
})
|
||||
rightSidePanelStore.togglePanel()
|
||||
}
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
|
||||
*/
|
||||
useEventListener(dragHandleRef, 'mousedown', () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'actionbar_run_handle_drag_start'
|
||||
button_id: 'actionbar_run_handle_drag_start',
|
||||
element_group: 'actionbar'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.onChangeTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_on_change_selected'
|
||||
button_id: 'queue_mode_option_run_on_change_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'change'
|
||||
}
|
||||
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
button_id: 'queue_mode_option_run_instant_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
}
|
||||
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
button_id: 'queue_run_multiple_batches_submitted',
|
||||
element_group: 'queue'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@ const home = computed(() => ({
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
button_id: 'breadcrumb_subgraph_root_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -103,7 +104,8 @@ const items = computed(() => {
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
button_id: 'breadcrumb_subgraph_item_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import BackgroundImageUpload from './BackgroundImageUpload.vue'
|
||||
|
||||
const fetchApi = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({ api: { fetchApi } }))
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
interface ImageUploadStubProps {
|
||||
modelValue?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const imageUploadEmit = vi.hoisted(() => ({ current: null as null | unknown }))
|
||||
|
||||
const ImageUploadStub = {
|
||||
props: ['modelValue', 'loading'],
|
||||
emits: ['update:modelValue', 'fileSelected'],
|
||||
setup(_: ImageUploadStubProps, { emit }: { emit: unknown }) {
|
||||
imageUploadEmit.current = emit
|
||||
return () => null
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpload(modelValue = '') {
|
||||
const onUpdate = vi.fn()
|
||||
const utils = render(BackgroundImageUpload, {
|
||||
props: { modelValue, 'onUpdate:modelValue': onUpdate },
|
||||
global: {
|
||||
plugins: [i18n, createTestingPinia({ stubActions: false })],
|
||||
stubs: { ImageUpload: ImageUploadStub }
|
||||
}
|
||||
})
|
||||
const selectFile = (file: File) =>
|
||||
(imageUploadEmit.current as (e: string, f: File) => void)(
|
||||
'fileSelected',
|
||||
file
|
||||
)
|
||||
return { ...utils, onUpdate, selectFile }
|
||||
}
|
||||
|
||||
const testFile = () => new File(['x'], 'photo.png', { type: 'image/png' })
|
||||
|
||||
describe('BackgroundImageUpload', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
fetchApi.mockReset()
|
||||
})
|
||||
|
||||
it('sets the model to an /api/view URL after a successful upload', async () => {
|
||||
fetchApi.mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ name: 'photo.png', subfolder: 'backgrounds' })
|
||||
})
|
||||
const { onUpdate, selectFile } = renderUpload()
|
||||
|
||||
await selectFile(testFile())
|
||||
await vi.waitFor(() => expect(onUpdate).toHaveBeenCalled())
|
||||
|
||||
const url = onUpdate.mock.calls.at(-1)?.[0] as string
|
||||
expect(url).toMatch(/^\/api\/view\?/)
|
||||
expect(url).toContain('filename=photo.png')
|
||||
expect(url).toContain('subfolder=backgrounds')
|
||||
// The uploaded folder is not duplicated into the filename param
|
||||
expect(url).not.toContain('filename=backgrounds')
|
||||
})
|
||||
|
||||
it('shows a toast and does not set the model when upload fails', async () => {
|
||||
fetchApi.mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({})
|
||||
})
|
||||
const { onUpdate, selectFile } = renderUpload()
|
||||
|
||||
await selectFile(testFile())
|
||||
await vi.waitFor(() =>
|
||||
expect(useToastStore().addAlert).toHaveBeenCalledWith(
|
||||
'Failed to upload background image'
|
||||
)
|
||||
)
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when the request throws', async () => {
|
||||
fetchApi.mockRejectedValue(new Error('network down'))
|
||||
const { selectFile } = renderUpload()
|
||||
|
||||
await selectFile(testFile())
|
||||
await vi.waitFor(() =>
|
||||
expect(useToastStore().addAlert).toHaveBeenCalledWith(
|
||||
'Error uploading background image: Error: network down'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,58 @@
|
||||
<template>
|
||||
<ImageUpload
|
||||
v-model="modelValue"
|
||||
:loading="isUploading"
|
||||
@file-selected="handleFileUpload"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
v-model="modelValue"
|
||||
class="flex-1"
|
||||
:placeholder="$t('g.imageUrl')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="$t('g.upload')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="$t('g.upload')"
|
||||
:disabled="isUploading"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.clear')"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
:disabled="!modelValue"
|
||||
@click="clearImage"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ImageUpload from '@/components/ui/image-upload/ImageUpload.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
@@ -32,35 +64,46 @@ const uploadFile = async (file: File): Promise<string | null> => {
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(t('toastMessages.failedToUploadBackgroundImage'))
|
||||
useToastStore().addAlert(
|
||||
`Upload failed: ${resp.status} - ${resp.statusText}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.name
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const uploadedName = await uploadFile(file)
|
||||
if (uploadedName) {
|
||||
const params = new URLSearchParams({
|
||||
filename: uploadedName,
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0]
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
const uploadedPath = await uploadFile(file)
|
||||
if (uploadedPath) {
|
||||
// Set the value to the API view URL with subfolder parameter
|
||||
const params = new URLSearchParams({
|
||||
filename: uploadedPath,
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(`Upload error: ${String(error)}`)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorUploadingBackgroundImage', {
|
||||
error: String(error)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
modelValue.value = ''
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,6 @@ const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200,
|
||||
@@ -42,7 +41,6 @@ const {
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: CSSProperties
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
@@ -61,7 +59,6 @@ const itemWidth = ref(defaultItemWidth)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { width, height } = useElementSize(container)
|
||||
const { y: scrollY } = useScroll(container, {
|
||||
throttle: scrollThrottle,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
messages: {
|
||||
en: { g: { close: 'Close', maximizeDialog: 'Maximize' } }
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -193,6 +195,68 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies headerClass and bodyClass on the non-headless path', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-section-classes',
|
||||
title: 'Section classes',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'p-0'
|
||||
}
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const header = screen.getByText('Section classes').parentElement
|
||||
expect(header?.classList.contains('p-2')).toBe(true)
|
||||
// twMerge drops the default header padding in favor of headerClass
|
||||
expect(header?.classList.contains('px-4')).toBe(false)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const body = screen.getByTestId('body').parentElement
|
||||
expect(body?.classList.contains('p-0')).toBe(true)
|
||||
expect(body?.classList.contains('px-4')).toBe(false)
|
||||
})
|
||||
|
||||
it('maximize overrides custom dimension classes from contentClass', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-maximize-wins',
|
||||
title: 'Maximize wins',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
maximizable: true,
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]'
|
||||
}
|
||||
})
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Maximize' }))
|
||||
|
||||
// Maximized dimensions win over the caller's fixed dimensions,
|
||||
// mirroring PrimeVue's `.p-dialog-maximized` !important behavior.
|
||||
expect(dialog.classList.contains('size-auto')).toBe(true)
|
||||
expect(dialog.classList.contains('max-h-none')).toBe(true)
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('sm:max-w-[80vw]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
@@ -238,6 +302,22 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
|
||||
// A backgrounded dialog must never dismiss on an outside pointer — the
|
||||
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding
|
||||
// opening over Settings). Target is outside any overlay, so only the
|
||||
// is-active gate can prevent it.
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('allows the top-most dialog to dismiss on a true outside pointer', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
|
||||
@@ -18,13 +18,19 @@
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@open-auto-focus="(e) => onRekaOpenAutoFocus(e, item.key)"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
(e) =>
|
||||
onRekaPointerDownOutside(
|
||||
item.dialogComponentProps,
|
||||
e,
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
@@ -37,7 +43,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader>
|
||||
<DialogHeader :class="item.dialogComponentProps.headerClass">
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
@@ -58,14 +64,24 @@
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 overflow-auto px-4 py-2',
|
||||
item.dialogComponentProps.bodyClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<DialogFooter
|
||||
v-if="item.footerComponent"
|
||||
:class="item.dialogComponentProps.footerClass"
|
||||
>
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
@@ -109,6 +125,8 @@
|
||||
<script setup lang="ts">
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
@@ -136,6 +154,22 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
// Reka's FocusScope focuses the first tabbable element on open (often a header
|
||||
// or footer button). Dialog content that marks an input with `autofocus` (e.g.
|
||||
// the keybinding capture input, the prompt input) relied on PrimeVue honoring
|
||||
// that attribute, so honor it here: focus the autofocus target and cancel
|
||||
// Reka's default auto-focus when one is present.
|
||||
function onRekaOpenAutoFocus(event: Event, key: string) {
|
||||
const content = document.querySelector<HTMLElement>(
|
||||
`[aria-labelledby="${CSS.escape(key)}"]`
|
||||
)
|
||||
const autofocusEl = content?.querySelector<HTMLElement>('[autofocus]')
|
||||
if (autofocusEl) {
|
||||
event.preventDefault()
|
||||
autofocusEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ const reportOpen = ref(false)
|
||||
*/
|
||||
const showReport = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_show_report_clicked'
|
||||
button_id: 'error_dialog_show_report_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
reportOpen.value = true
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
button_id: 'error_dialog_find_existing_issues_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
|
||||