Compare commits
41 Commits
fix/dropdo
...
cloud/v1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f319f6cb4 | ||
|
|
9da62f172f | ||
|
|
7653a875e3 | ||
|
|
1272310de2 | ||
|
|
de94834f7f | ||
|
|
c0aa3a7deb | ||
|
|
3b29e89abf | ||
|
|
8652f1787e | ||
|
|
39cef83b54 | ||
|
|
9e632b56c5 | ||
|
|
ee16fe61ce | ||
|
|
c5949ca078 | ||
|
|
03fe37c14b | ||
|
|
18696b0987 | ||
|
|
488f059303 | ||
|
|
789e5bd115 | ||
|
|
9399ac13df | ||
|
|
7bd84061c3 | ||
|
|
527dca0d93 | ||
|
|
c76397f537 | ||
|
|
f24964d0b7 | ||
|
|
0a01207fea | ||
|
|
99d54e1c76 | ||
|
|
5ad5c41262 | ||
|
|
b6f439f139 | ||
|
|
9b2439944c | ||
|
|
1e2692bc5e | ||
|
|
588a84d167 | ||
|
|
4924c71336 | ||
|
|
e6985808c5 | ||
|
|
206d814e74 | ||
|
|
ae8da8fc19 | ||
|
|
bd85253dbe | ||
|
|
8dd3ee072e | ||
|
|
e49d1afa61 | ||
|
|
c7943ca1b6 | ||
|
|
9816951a39 | ||
|
|
e7c10aaf77 | ||
|
|
1ddc0bb125 | ||
|
|
fe8dc17d2d | ||
|
|
352f5a0cd4 |
BIN
.github/pr-images/fe-237-before-after.png
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
12
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -64,15 +64,17 @@ jobs:
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v7
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: playwright-report-*
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-.*
|
||||
name_is_regexp: true
|
||||
path: reports
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && hashFiles('reports/**') != ''
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
51
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -15,7 +14,26 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
should_run:
|
||||
- '!(**.md)'
|
||||
|
||||
setup:
|
||||
needs: changes
|
||||
if: github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -153,9 +171,9 @@ jobs:
|
||||
|
||||
# Merge sharded test reports (no container needed - only runs CLI)
|
||||
merge-reports:
|
||||
needs: [playwright-tests-chromium-sharded]
|
||||
needs: [changes, playwright-tests-chromium-sharded]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true') }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
@@ -190,8 +208,9 @@ jobs:
|
||||
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -210,9 +229,9 @@ jobs:
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [playwright-tests, merge-reports]
|
||||
needs: [changes, playwright-tests, merge-reports]
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
@@ -237,4 +256,24 @@ jobs:
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
e2e-status:
|
||||
if: always()
|
||||
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Determine e2e outcome
|
||||
run: |
|
||||
if [[ "${{ needs.changes.outputs.should_run }}" != "true" && "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "E2E tests skipped (no relevant changes)"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${{ needs.playwright-tests-chromium-sharded.result }}" == "success" && "${{ needs.playwright-tests.result }}" == "success" ]]; then
|
||||
echo "All E2E tests passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "E2E tests failed or were cancelled"
|
||||
echo " chromium-sharded: ${{ needs.playwright-tests-chromium-sharded.result }}"
|
||||
echo " playwright-tests: ${{ needs.playwright-tests.result }}"
|
||||
exit 1
|
||||
#### END Deployment and commenting (non-forked PRs only)
|
||||
|
||||
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: 'CI: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
run: |
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: vercel-preview
|
||||
path: temp/vercel-preview
|
||||
|
||||
deploy-production:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
74
.github/workflows/pr-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: 'PR: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Vercel Website Preview']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download preview metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: vercel-preview
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/vercel-preview
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
|
||||
- name: Read preview URL
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: meta
|
||||
run: |
|
||||
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
background-color: var(--color-coral-700);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
background-color: var(--color-coral-600);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
background-color: var(--color-coral-500);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
{ icon: '📚', label: t('academy.tutorials', locale) },
|
||||
{ icon: '🎥', label: t('academy.videos', locale) },
|
||||
{ icon: '🛠️', label: t('academy.projects', locale) }
|
||||
])
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,15 +13,14 @@ const features = computed(() => [
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
{{ t('academy.badge', locale) }}
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">
|
||||
{{ t('academy.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('academy.body', locale) }}
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
@@ -47,7 +40,7 @@ const features = computed(() => [
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('academy.cta', locale) }}
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards = computed(() => [
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: t('cta.desktop.title', locale),
|
||||
description: t('cta.desktop.desc', locale),
|
||||
cta: t('cta.desktop.cta', locale),
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: t('cta.cloud.title', locale),
|
||||
description: t('cta.cloud.desc', locale),
|
||||
cta: t('cta.cloud.cta', locale),
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: t('cta.api.title', locale),
|
||||
description: t('cta.api.desc', locale),
|
||||
cta: t('cta.api.cta', locale),
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
{{ t('cta.heading', locale) }}
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = computed(() => [
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: t('getStarted.step1.title', locale),
|
||||
description: t('getStarted.step1.desc', locale)
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: t('getStarted.step2.title', locale),
|
||||
description: t('getStarted.step2.desc', locale)
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: t('getStarted.step3.title', locale),
|
||||
description: t('getStarted.step3.desc', locale)
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('getStarted.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('getStarted.subheading', locale) }}
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
@@ -62,7 +55,7 @@ const steps = computed(() => [
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('getStarted.cta', locale) }}
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const ctaButtons = computed(() => [
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: t('hero.cta.getStarted', locale),
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: t('hero.cta.learnMore', locale),
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,11 +39,12 @@ const ctaButtons = computed(() => [
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
{{ t('hero.headline', locale) }}
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
{{ t('hero.subheadline', locale) }}
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
@@ -14,11 +7,13 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
{{ t('manifesto.heading', locale) }}
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
{{ t('manifesto.body', locale) }}
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const features = computed(() => [
|
||||
t('showcase.nodeEditor', locale),
|
||||
t('showcase.realTimePreview', locale),
|
||||
t('showcase.versionControl', locale)
|
||||
])
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,11 +8,9 @@ const features = computed(() => [
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('showcase.heading', locale) }}
|
||||
</h2>
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
{{ t('showcase.subheading', locale) }}
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +28,7 @@ const features = computed(() => [
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ t('showcase.placeholder', locale) }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const columns = computed(() => [
|
||||
const columns = [
|
||||
{
|
||||
title: t('footer.product', locale),
|
||||
title: 'Product',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.comfyDesktop', locale),
|
||||
href: localePath('/download', locale)
|
||||
},
|
||||
{ label: t('footer.comfyCloud', locale), href: 'https://app.comfy.org' },
|
||||
{ label: t('footer.comfyHub', locale), href: 'https://hub.comfy.org' },
|
||||
{
|
||||
label: t('footer.pricing', locale),
|
||||
href: localePath('/pricing', locale)
|
||||
}
|
||||
{ label: 'Comfy Desktop', href: '/download' },
|
||||
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
|
||||
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
|
||||
{ label: 'Pricing', href: '/pricing' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.documentation', locale),
|
||||
href: 'https://docs.comfy.org'
|
||||
},
|
||||
{ label: t('footer.blog', locale), href: 'https://blog.comfy.org' },
|
||||
{
|
||||
label: t('footer.gallery', locale),
|
||||
href: localePath('/gallery', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.github', locale),
|
||||
href: 'https://github.com/comfyanonymous/ComfyUI'
|
||||
}
|
||||
{ label: 'Documentation', href: 'https://docs.comfy.org' },
|
||||
{ label: 'Blog', href: 'https://blog.comfy.org' },
|
||||
{ label: 'Gallery', href: '/gallery' },
|
||||
{ label: 'GitHub', href: 'https://github.com/comfyanonymous/ComfyUI' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.company', locale),
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: localePath('/about', locale) },
|
||||
{
|
||||
label: t('footer.careers', locale),
|
||||
href: localePath('/careers', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
}
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Careers', href: '/careers' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.legal', locale),
|
||||
title: 'Legal',
|
||||
links: [
|
||||
{
|
||||
label: t('footer.terms', locale),
|
||||
href: localePath('/terms-of-service', locale)
|
||||
},
|
||||
{
|
||||
label: t('footer.privacy', locale),
|
||||
href: localePath('/privacy-policy', locale)
|
||||
}
|
||||
{ label: 'Terms of Service', href: '/terms-of-service' },
|
||||
{ label: 'Privacy Policy', href: '/privacy-policy' }
|
||||
]
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
const socials = [
|
||||
{
|
||||
@@ -110,16 +76,11 @@ const socials = [
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="lg:col-span-1">
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
<p class="mt-4 text-sm text-smoke-700">
|
||||
{{ t('footer.tagline', locale) }}
|
||||
Professional control of visual AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,8 +113,7 @@ const socials = [
|
||||
class="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 p-6 sm:flex-row"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
© {{ new Date().getFullYear() }}
|
||||
{{ t('footer.copyright', locale) }}
|
||||
© {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
|
||||
</p>
|
||||
|
||||
<!-- Social icons -->
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { localePath, t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const currentPath = ref('')
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{
|
||||
label: t('nav.enterprise', locale),
|
||||
href: localePath('/enterprise', locale)
|
||||
},
|
||||
{ label: t('nav.gallery', locale), href: localePath('/gallery', locale) },
|
||||
{ label: t('nav.about', locale), href: localePath('/about', locale) },
|
||||
{ label: t('nav.careers', locale), href: localePath('/careers', locale) }
|
||||
])
|
||||
const navLinks = [
|
||||
{ label: 'ENTERPRISE', href: '/enterprise' },
|
||||
{ label: 'GALLERY', href: '/gallery' },
|
||||
{ label: 'ABOUT', href: '/about' },
|
||||
{ label: 'CAREERS', href: '/careers' }
|
||||
]
|
||||
|
||||
const ctaLinks = [
|
||||
{
|
||||
@@ -57,19 +49,14 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
:aria-label="t('nav.ariaLabel', locale)"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<!-- Logo -->
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<a
|
||||
:href="localePath('/', locale)"
|
||||
class="text-2xl font-bold text-brand-yellow italic"
|
||||
>
|
||||
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
|
||||
Comfy
|
||||
</a>
|
||||
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||
|
||||
<!-- Desktop nav links -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
@@ -90,8 +77,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-sm font-semibold"
|
||||
>
|
||||
@@ -103,7 +90,7 @@ onUnmounted(() => {
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
class="flex flex-col gap-1.5 md:hidden"
|
||||
:aria-label="t('nav.toggleMenu', locale)"
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="site-mobile-menu"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
@@ -148,8 +135,8 @@ onUnmounted(() => {
|
||||
:href="cta.href"
|
||||
:class="
|
||||
cta.primary
|
||||
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
|
||||
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
|
||||
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
|
||||
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
|
||||
"
|
||||
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
@@ -20,11 +14,11 @@ const logos = [
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ value: '60K+', label: t('social.customNodes', locale) },
|
||||
{ value: '106K+', label: t('social.githubStars', locale) },
|
||||
{ value: '500K+', label: t('social.communityMembers', locale) }
|
||||
])
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +28,7 @@ const metrics = computed(() => [
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
{{ t('social.heading', locale) }}
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const industryKeys = [
|
||||
'All',
|
||||
'VFX',
|
||||
'Gaming',
|
||||
'Advertising',
|
||||
'Photography'
|
||||
] as const
|
||||
|
||||
const industryLabels = computed(() => ({
|
||||
All: t('testimonials.all', locale),
|
||||
VFX: t('testimonials.vfx', locale),
|
||||
Gaming: t('testimonials.gaming', locale),
|
||||
Advertising: t('testimonials.advertising', locale),
|
||||
Photography: t('testimonials.photography', locale)
|
||||
}))
|
||||
|
||||
const activeFilter = ref<(typeof industryKeys)[number]>('All')
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
@@ -31,7 +12,7 @@ const testimonials = [
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX' as const
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -39,7 +20,7 @@ const testimonials = [
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming' as const
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
@@ -47,7 +28,7 @@ const testimonials = [
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising' as const
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -61,13 +42,13 @@ const filteredTestimonials = computed(() => {
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
{{ t('testimonials.heading', locale) }}
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industryKeys"
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
type="button"
|
||||
:aria-pressed="activeFilter === industry"
|
||||
@@ -79,7 +60,7 @@ const filteredTestimonials = computed(() => {
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industryLabels[industry] }}
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +85,7 @@ const filteredTestimonials = computed(() => {
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const categories = computed(() => [
|
||||
t('useCase.vfx', locale),
|
||||
t('useCase.agencies', locale),
|
||||
t('useCase.gaming', locale),
|
||||
t('useCase.ecommerce', locale),
|
||||
t('useCase.community', locale)
|
||||
])
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
@@ -31,7 +27,7 @@ const activeCategory = ref(0)
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('useCase.heading', locale) }}
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
@@ -56,14 +52,15 @@ const activeCategory = ref(0)
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
{{ t('useCase.body', locale) }}
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
{{ t('useCase.cta', locale) }}
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const pillars = computed(() => [
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: t('pillars.buildTitle', locale),
|
||||
description: t('pillars.buildDesc', locale)
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: t('pillars.customizeTitle', locale),
|
||||
description: t('pillars.customizeDesc', locale)
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: t('pillars.refineTitle', locale),
|
||||
description: t('pillars.refineDesc', locale)
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: t('pillars.automateTitle', locale),
|
||||
description: t('pillars.automateDesc', locale)
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: t('pillars.runTitle', locale),
|
||||
description: t('pillars.runDesc', locale)
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
])
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,10 +36,10 @@ const pillars = computed(() => [
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
{{ t('pillars.heading', locale) }}
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
{{ t('pillars.subheading', locale) }}
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
type Locale = 'en' | 'zh-CN'
|
||||
|
||||
const translations = {
|
||||
// HeroSection
|
||||
'hero.headline': {
|
||||
en: 'Professional Control of Visual AI',
|
||||
'zh-CN': '视觉 AI 的专业控制'
|
||||
},
|
||||
'hero.subheadline': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.cta.getStarted': { en: 'GET STARTED', 'zh-CN': '立即开始' },
|
||||
'hero.cta.learnMore': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
|
||||
// SocialProofBar
|
||||
'social.heading': {
|
||||
en: 'Trusted by Industry Leaders',
|
||||
'zh-CN': '受到行业领导者的信赖'
|
||||
},
|
||||
'social.customNodes': { en: 'Custom Nodes', 'zh-CN': '自定义节点' },
|
||||
'social.githubStars': { en: 'GitHub Stars', 'zh-CN': 'GitHub 星标' },
|
||||
'social.communityMembers': {
|
||||
en: 'Community Members',
|
||||
'zh-CN': '社区成员'
|
||||
},
|
||||
|
||||
// ProductShowcase
|
||||
'showcase.heading': { en: 'See Comfy in Action', 'zh-CN': '观看 Comfy 实战' },
|
||||
'showcase.subheading': {
|
||||
en: 'Watch how professionals build AI workflows with unprecedented control',
|
||||
'zh-CN': '观看专业人士如何以前所未有的控制力构建 AI 工作流'
|
||||
},
|
||||
'showcase.placeholder': {
|
||||
en: 'Workflow Demo Coming Soon',
|
||||
'zh-CN': '工作流演示即将推出'
|
||||
},
|
||||
'showcase.nodeEditor': { en: 'Node-Based Editor', 'zh-CN': '节点编辑器' },
|
||||
'showcase.realTimePreview': {
|
||||
en: 'Real-Time Preview',
|
||||
'zh-CN': '实时预览'
|
||||
},
|
||||
'showcase.versionControl': {
|
||||
en: 'Version Control',
|
||||
'zh-CN': '版本控制'
|
||||
},
|
||||
|
||||
// ValuePillars
|
||||
'pillars.heading': {
|
||||
en: 'The Building Blocks of AI Production',
|
||||
'zh-CN': 'AI 制作的基本要素'
|
||||
},
|
||||
'pillars.subheading': {
|
||||
en: 'Five powerful capabilities that give you complete control',
|
||||
'zh-CN': '五大强大功能,让您完全掌控'
|
||||
},
|
||||
'pillars.buildTitle': { en: 'Build', 'zh-CN': '构建' },
|
||||
'pillars.buildDesc': {
|
||||
en: 'Design complex AI workflows visually with our node-based editor',
|
||||
'zh-CN': '使用节点编辑器直观地设计复杂的 AI 工作流'
|
||||
},
|
||||
'pillars.customizeTitle': { en: 'Customize', 'zh-CN': '自定义' },
|
||||
'pillars.customizeDesc': {
|
||||
en: 'Fine-tune every parameter across any model architecture',
|
||||
'zh-CN': '在任何模型架构中微调每个参数'
|
||||
},
|
||||
'pillars.refineTitle': { en: 'Refine', 'zh-CN': '优化' },
|
||||
'pillars.refineDesc': {
|
||||
en: 'Iterate on outputs with precision controls and real-time preview',
|
||||
'zh-CN': '通过精确控制和实时预览迭代输出'
|
||||
},
|
||||
'pillars.automateTitle': { en: 'Automate', 'zh-CN': '自动化' },
|
||||
'pillars.automateDesc': {
|
||||
en: 'Scale your workflows with batch processing and API integration',
|
||||
'zh-CN': '通过批处理和 API 集成扩展工作流'
|
||||
},
|
||||
'pillars.runTitle': { en: 'Run', 'zh-CN': '运行' },
|
||||
'pillars.runDesc': {
|
||||
en: 'Deploy locally or in the cloud with identical results',
|
||||
'zh-CN': '在本地或云端部署,获得相同的结果'
|
||||
},
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.heading': {
|
||||
en: 'Built for Every Creative Industry',
|
||||
'zh-CN': '为每个创意行业而生'
|
||||
},
|
||||
'useCase.vfx': { en: 'VFX & Animation', 'zh-CN': '视觉特效与动画' },
|
||||
'useCase.agencies': { en: 'Creative Agencies', 'zh-CN': '创意机构' },
|
||||
'useCase.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'useCase.ecommerce': {
|
||||
en: 'eCommerce & Fashion',
|
||||
'zh-CN': '电商与时尚'
|
||||
},
|
||||
'useCase.community': {
|
||||
en: 'Community & Hobbyists',
|
||||
'zh-CN': '社区与爱好者'
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows, and a community that builds faster than any one company could.',
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': { en: 'EXPLORE WORKFLOWS', 'zh-CN': '探索工作流' },
|
||||
|
||||
// CaseStudySpotlight
|
||||
'caseStudy.heading': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'caseStudy.subheading': {
|
||||
en: 'See how leading studios use Comfy in production',
|
||||
'zh-CN': '了解领先工作室如何在生产中使用 Comfy'
|
||||
},
|
||||
'caseStudy.readMore': { en: 'READ CASE STUDY', 'zh-CN': '阅读案例' },
|
||||
|
||||
// TestimonialsSection
|
||||
'testimonials.heading': {
|
||||
en: 'What Professionals Say',
|
||||
'zh-CN': '专业人士的评价'
|
||||
},
|
||||
'testimonials.all': { en: 'All', 'zh-CN': '全部' },
|
||||
'testimonials.vfx': { en: 'VFX', 'zh-CN': '特效' },
|
||||
'testimonials.gaming': { en: 'Gaming', 'zh-CN': '游戏' },
|
||||
'testimonials.advertising': { en: 'Advertising', 'zh-CN': '广告' },
|
||||
'testimonials.photography': { en: 'Photography', 'zh-CN': '摄影' },
|
||||
|
||||
// GetStartedSection
|
||||
'getStarted.heading': {
|
||||
en: 'Get Started in Minutes',
|
||||
'zh-CN': '几分钟即可开始'
|
||||
},
|
||||
'getStarted.subheading': {
|
||||
en: 'From download to your first AI-generated output in three simple steps',
|
||||
'zh-CN': '从下载到首次 AI 生成输出,只需三个简单步骤'
|
||||
},
|
||||
'getStarted.step1.title': {
|
||||
en: 'Download & Sign Up',
|
||||
'zh-CN': '下载与注册'
|
||||
},
|
||||
'getStarted.step1.desc': {
|
||||
en: 'Get Comfy Desktop for free or create a Cloud account',
|
||||
'zh-CN': '免费获取 Comfy Desktop 或创建云端账号'
|
||||
},
|
||||
'getStarted.step2.title': {
|
||||
en: 'Load a Workflow',
|
||||
'zh-CN': '加载工作流'
|
||||
},
|
||||
'getStarted.step2.desc': {
|
||||
en: 'Choose from thousands of community workflows or build your own',
|
||||
'zh-CN': '从数千个社区工作流中选择,或自行构建'
|
||||
},
|
||||
'getStarted.step3.title': { en: 'Generate', 'zh-CN': '生成' },
|
||||
'getStarted.step3.desc': {
|
||||
en: 'Hit run and watch your AI workflow come to life',
|
||||
'zh-CN': '点击运行,观看 AI 工作流生动呈现'
|
||||
},
|
||||
'getStarted.cta': { en: 'DOWNLOAD COMFY', 'zh-CN': '下载 COMFY' },
|
||||
|
||||
// CTASection
|
||||
'cta.heading': {
|
||||
en: 'Choose Your Way to Comfy',
|
||||
'zh-CN': '选择您的 Comfy 方式'
|
||||
},
|
||||
'cta.desktop.title': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'cta.desktop.desc': {
|
||||
en: 'Full power on your local machine. Free and open source.',
|
||||
'zh-CN': '在本地机器上释放全部性能。免费开源。'
|
||||
},
|
||||
'cta.desktop.cta': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
'cta.cloud.title': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'cta.cloud.desc': {
|
||||
en: 'Run workflows in the cloud. No GPU required.',
|
||||
'zh-CN': '在云端运行工作流,无需 GPU。'
|
||||
},
|
||||
'cta.cloud.cta': { en: 'TRY CLOUD', 'zh-CN': '试用云端' },
|
||||
'cta.api.title': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'cta.api.desc': {
|
||||
en: 'Integrate AI generation into your applications.',
|
||||
'zh-CN': '将 AI 生成功能集成到您的应用程序中。'
|
||||
},
|
||||
'cta.api.cta': { en: 'VIEW DOCS', 'zh-CN': '查看文档' },
|
||||
|
||||
// ManifestoSection
|
||||
'manifesto.heading': { en: 'Method, Not Magic', 'zh-CN': '方法,而非魔法' },
|
||||
'manifesto.body': {
|
||||
en: 'We believe in giving creators real control over AI. Not black boxes. Not magic buttons. But transparent, reproducible, node-by-node control over every step of the creative process.',
|
||||
'zh-CN':
|
||||
'我们相信应赋予创作者对 AI 的真正控制权。没有黑箱,没有魔法按钮,而是对创作过程每一步的透明、可复现、逐节点控制。'
|
||||
},
|
||||
|
||||
// AcademySection
|
||||
'academy.badge': { en: 'COMFY ACADEMY', 'zh-CN': 'COMFY 学院' },
|
||||
'academy.heading': {
|
||||
en: 'Master AI Workflows',
|
||||
'zh-CN': '掌握 AI 工作流'
|
||||
},
|
||||
'academy.body': {
|
||||
en: 'Learn to build professional AI workflows with guided tutorials, video courses, and hands-on projects.',
|
||||
'zh-CN': '通过指导教程、视频课程和实践项目,学习构建专业的 AI 工作流。'
|
||||
},
|
||||
'academy.tutorials': { en: 'Guided Tutorials', 'zh-CN': '指导教程' },
|
||||
'academy.videos': { en: 'Video Courses', 'zh-CN': '视频课程' },
|
||||
'academy.projects': { en: 'Hands-on Projects', 'zh-CN': '实践项目' },
|
||||
'academy.cta': { en: 'EXPLORE ACADEMY', 'zh-CN': '探索学院' },
|
||||
|
||||
// SiteNav
|
||||
'nav.ariaLabel': { en: 'Main navigation', 'zh-CN': '主导航' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.enterprise': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'nav.gallery': { en: 'GALLERY', 'zh-CN': '画廊' },
|
||||
'nav.about': { en: 'ABOUT', 'zh-CN': '关于' },
|
||||
'nav.careers': { en: 'CAREERS', 'zh-CN': '招聘' },
|
||||
'nav.cloud': { en: 'COMFY CLOUD', 'zh-CN': 'COMFY 云端' },
|
||||
'nav.hub': { en: 'COMFY HUB', 'zh-CN': 'COMFY HUB' },
|
||||
|
||||
// SiteFooter
|
||||
'footer.tagline': {
|
||||
en: 'Professional control of visual AI.',
|
||||
'zh-CN': '视觉 AI 的专业控制。'
|
||||
},
|
||||
'footer.product': { en: 'Product', 'zh-CN': '产品' },
|
||||
'footer.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'footer.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'footer.legal': { en: 'Legal', 'zh-CN': '法律' },
|
||||
'footer.copyright': {
|
||||
en: 'Comfy Org. All rights reserved.',
|
||||
'zh-CN': 'Comfy Org. 保留所有权利。'
|
||||
},
|
||||
'footer.comfyDesktop': { en: 'Comfy Desktop', 'zh-CN': 'Comfy Desktop' },
|
||||
'footer.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'footer.comfyHub': { en: 'ComfyHub', 'zh-CN': 'ComfyHub' },
|
||||
'footer.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
'footer.documentation': { en: 'Documentation', 'zh-CN': '文档' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'footer.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'footer.about': { en: 'About', 'zh-CN': '关于' },
|
||||
'footer.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'footer.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
|
||||
'footer.terms': { en: 'Terms of Service', 'zh-CN': '服务条款' },
|
||||
'footer.privacy': { en: 'Privacy Policy', 'zh-CN': '隐私政策' }
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
type TranslationKey = keyof typeof translations
|
||||
|
||||
export function t(key: TranslationKey, locale: Locale = 'en'): string {
|
||||
return translations[key][locale] ?? translations[key].en
|
||||
}
|
||||
|
||||
export function localePath(path: string, locale: Locale): string {
|
||||
return locale === 'en' ? path : `/${locale}${path}`
|
||||
}
|
||||
|
||||
export type { Locale }
|
||||
@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
|
||||
const team = [
|
||||
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
|
||||
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager 和 Impact/Inspire Pack 作者' },
|
||||
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
|
||||
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
|
||||
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
|
||||
{ name: 'christian-byrne', role: '全栈开发工程师' },
|
||||
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved 和 Advanced-ControlNet 作者' },
|
||||
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
|
||||
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
|
||||
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
|
||||
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
|
||||
{ name: 'comfyanonymous', role: 'Creator of ComfyUI, cofounder' },
|
||||
{ name: 'Dr.Lt.Data', role: 'Creator of ComfyUI-Manager and Impact/Inspire Pack' },
|
||||
{ name: 'pythongosssss', role: 'Major contributor, creator of ComfyUI-Custom-Scripts' },
|
||||
{ name: 'yoland68', role: 'Creator of ComfyCLI, cofounder, ex-Google' },
|
||||
{ name: 'robinjhuang', role: 'Maintains Comfy Registry, cofounder, ex-Google Cloud' },
|
||||
{ name: 'jojodecay', role: 'ComfyUI event series host, community & partnerships' },
|
||||
{ name: 'christian-byrne', role: 'Fullstack developer' },
|
||||
{ name: 'Kosinkadink', role: 'Creator of AnimateDiff-Evolved and Advanced-ControlNet' },
|
||||
{ name: 'webfiltered', role: 'Overhauled Litegraph library' },
|
||||
{ name: 'Pablo', role: 'Product Design, ex-AI startup founder' },
|
||||
{ name: 'ComfyUI Wiki (Daxiong)', role: 'Official docs and templates' },
|
||||
{ name: 'ctrlbenlu (Ben)', role: 'Software engineer, ex-robotics' },
|
||||
{ name: 'Purz Beats', role: 'Motion graphics designer and ML Engineer' },
|
||||
{ name: 'Ricyu (Rich)', role: 'Software engineer, ex-Meta' },
|
||||
]
|
||||
|
||||
const collaborators = [
|
||||
{ name: 'Yogo', role: '协作者' },
|
||||
{ name: 'Fill (Machine Delusions)', role: '协作者' },
|
||||
{ name: 'Julien (MJM)', role: '协作者' },
|
||||
{ name: 'Yogo', role: 'Collaborator' },
|
||||
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
|
||||
{ name: 'Julien (MJM)', role: 'Collaborator' },
|
||||
]
|
||||
|
||||
const projects = [
|
||||
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
|
||||
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
|
||||
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
|
||||
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
|
||||
{ name: 'Docs', description: '官方文档、指南和教程。' },
|
||||
{ name: 'ComfyUI', description: 'The core node-based interface for generative AI workflows.' },
|
||||
{ name: 'ComfyUI Manager', description: 'Install, update, and manage custom nodes with one click.' },
|
||||
{ name: 'Comfy Registry', description: 'The official registry for publishing and discovering custom nodes.' },
|
||||
{ name: 'Frontends', description: 'The desktop and web frontends that power the ComfyUI experience.' },
|
||||
{ name: 'Docs', description: 'Official documentation, guides, and tutorials.' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'ComfyUI 免费吗?',
|
||||
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
|
||||
q: 'Is ComfyUI free?',
|
||||
a: 'Yes. ComfyUI is free and open-source under the GPL-3.0 license. You can use it for personal and commercial projects.',
|
||||
},
|
||||
{
|
||||
q: '谁在开发 ComfyUI?',
|
||||
a: 'ComfyUI 由 comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
|
||||
q: 'Who is behind ComfyUI?',
|
||||
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
|
||||
},
|
||||
{
|
||||
q: '如何参与贡献?',
|
||||
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
|
||||
q: 'How can I contribute?',
|
||||
a: 'Check out our GitHub repositories to report issues, submit pull requests, or build custom nodes. Join our Discord community to connect with other contributors.',
|
||||
},
|
||||
{
|
||||
q: '未来有什么计划?',
|
||||
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
|
||||
q: 'What are the future plans?',
|
||||
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<!-- 主页横幅 -->
|
||||
<!-- Hero -->
|
||||
<section class="px-6 pb-24 pt-40 text-center">
|
||||
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
|
||||
开创视觉与音频媒体的下一个前沿
|
||||
Crafting the next frontier of visual and audio media
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
|
||||
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
|
||||
An open-source community and company building the most powerful tools for generative AI creators.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 我们的使命 -->
|
||||
<!-- Our Mission -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
|
||||
<p class="mt-6 text-3xl font-bold md:text-4xl">
|
||||
我们想打造生成式 AI 的操作系统。
|
||||
We want to build the operating system for Gen AI.
|
||||
</p>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
|
||||
从图像和视频合成到音频生成,ComfyUI 提供了一个模块化的
|
||||
节点式环境,让专业人士和爱好者可以创建、迭代
|
||||
和部署生产级工作流——没有黑箱。
|
||||
We're building the foundational tools that give creators full control over generative AI.
|
||||
From image and video synthesis to audio generation, ComfyUI provides a modular,
|
||||
node-based environment where professionals and enthusiasts can craft, iterate,
|
||||
and deploy production-quality workflows — without black boxes.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我们做什么? -->
|
||||
<!-- What Do We Do? -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">What Do We Do?</h2>
|
||||
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
|
||||
@@ -98,23 +98,24 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我们是谁 -->
|
||||
<!-- Who We Are -->
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Who We Are</h2>
|
||||
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
|
||||
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
|
||||
创作者、开发者和研究者社区。今天,Comfy Org 是一个位于旧金山的小型扁平化团队,
|
||||
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
|
||||
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
|
||||
ComfyUI started as a personal project by comfyanonymous and grew into a global community
|
||||
of creators, developers, and researchers. Today, Comfy Org is a small, flat team based in
|
||||
San Francisco, backed by investors who believe in open-source AI tooling. We work
|
||||
alongside an incredible community of contributors who build custom nodes, share workflows,
|
||||
and push the boundaries of what's possible with generative AI.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 团队 -->
|
||||
<!-- Team -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">Team</h2>
|
||||
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{team.map((member) => (
|
||||
<div class="rounded-xl border border-white/10 p-5 text-center">
|
||||
@@ -127,10 +128,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 协作者 -->
|
||||
<!-- Collaborators -->
|
||||
<section class="bg-charcoal-800 px-6 py-16">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="text-2xl font-bold">协作者</h2>
|
||||
<h2 class="text-2xl font-bold">Collaborators</h2>
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
{collaborators.map((person) => (
|
||||
<div class="text-center">
|
||||
@@ -142,10 +143,10 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 常见问题 -->
|
||||
<!-- FAQs -->
|
||||
<section class="px-6 py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
|
||||
<h2 class="text-center text-3xl font-bold md:text-4xl">FAQs</h2>
|
||||
<div class="mt-12 space-y-10">
|
||||
{faqs.map((faq) => (
|
||||
<div>
|
||||
@@ -157,19 +158,19 @@ const faqs = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 加入我们 CTA -->
|
||||
<!-- Join Our Team CTA -->
|
||||
<section class="bg-charcoal-800 px-6 py-24 text-center">
|
||||
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
|
||||
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
|
||||
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
|
||||
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
|
||||
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
|
||||
</p>
|
||||
<a
|
||||
href="/careers"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
查看开放职位
|
||||
View Open Positions
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -78,7 +78,7 @@ const questions = [
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<!-- Hero -->
|
||||
<section class="px-6 pb-24 pt-40">
|
||||
@@ -196,5 +196,5 @@ const questions = [
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -32,7 +32,7 @@ const cards = [
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
|
||||
<h1 class="text-4xl font-bold text-white md:text-5xl">
|
||||
下载 ComfyUI
|
||||
@@ -76,5 +76,5 @@ const cards = [
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="作品集 — Comfy">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="bg-black text-white">
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
|
||||
@@ -39,5 +39,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<SocialProofBar locale="zh-CN" />
|
||||
<ProductShowcase locale="zh-CN" />
|
||||
<ValuePillars locale="zh-CN" />
|
||||
<UseCaseSection locale="zh-CN" client:visible />
|
||||
<CaseStudySpotlight locale="zh-CN" />
|
||||
<TestimonialsSection locale="zh-CN" client:visible />
|
||||
<GetStartedSection locale="zh-CN" />
|
||||
<CTASection locale="zh-CN" />
|
||||
<ManifestoSection locale="zh-CN" />
|
||||
<AcademySection locale="zh-CN" />
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
|
||||
<p class="mt-2 text-sm text-smoke-500">生效日期:2025年4月18日</p>
|
||||
@@ -229,5 +229,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
description="ComfyUI 及相关 Comfy 服务的服务条款。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav locale="zh-CN" client:load />
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
<header class="mb-16">
|
||||
<h1 class="text-3xl font-bold text-white">服务条款</h1>
|
||||
@@ -216,5 +216,5 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter locale="zh-CN" />
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"outputDirectory": "apps/website/dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"github": {
|
||||
"enabled": false
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
|
||||
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"pos": [450, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
{
|
||||
"last_node_id": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"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": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
@@ -15,7 +35,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"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": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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": ["fake_model_a.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"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": ["fake_model_b.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model_a.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
{
|
||||
"name": "fake_model_b.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-bypassed-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 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": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [270, 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": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "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": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "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": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "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": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [225, 144],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [270, 108],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [270, 104],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [225, 104],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -154,12 +154,17 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
public readonly searchInput: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.activeWorkflowLabel = this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
this.searchInput = this.root.getByRole('combobox').first()
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
|
||||
@@ -2,9 +2,13 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class TemplatesDialog {
|
||||
public readonly root: Locator
|
||||
public readonly modelFilter: Locator
|
||||
public readonly resultsCount: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
this.modelFilter = this.root.getByRole('button', { name: /Model Filter/ })
|
||||
this.resultsCount = this.root.getByText(/Showing.*of.*templates/i)
|
||||
}
|
||||
|
||||
filterByHeading(name: string): Locator {
|
||||
@@ -16,4 +20,10 @@ export class TemplatesDialog {
|
||||
getCombobox(name: RegExp | string): Locator {
|
||||
return this.root.getByRole('combobox', { name })
|
||||
}
|
||||
|
||||
async selectModelOption(name: string): Promise<void> {
|
||||
await this.modelFilter.click()
|
||||
await this.page.getByRole('option', { name }).click()
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
|
||||
28
browser_tests/fixtures/data/templateFixtures.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
): TemplateInfo {
|
||||
return {
|
||||
description: overrides.name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function mockTemplateIndex(
|
||||
templates: TemplateInfo[]
|
||||
): WorkflowTemplates[] {
|
||||
return [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -114,6 +117,27 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -188,3 +212,13 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
95
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
page: Page,
|
||||
nodeIds: string[]
|
||||
): Promise<MeasureResult> {
|
||||
return page.evaluate(
|
||||
({ ids, padding }) => {
|
||||
const canvas = window.app!.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
const selectedItems = canvas.selectedItems
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const item of selectedItems) {
|
||||
const rect = item.boundingRect
|
||||
minX = Math.min(minX, rect[0])
|
||||
minY = Math.min(minY, rect[1])
|
||||
maxX = Math.max(maxX, rect[0] + rect[2])
|
||||
maxY = Math.max(maxY, rect[1] + rect[3])
|
||||
}
|
||||
const selectionBounds =
|
||||
selectedItems.size > 0
|
||||
? {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
w: maxX - minX + 2 * padding,
|
||||
h: maxY - minY + 2 * padding
|
||||
}
|
||||
: null
|
||||
|
||||
const canvasEl = canvas.canvas as HTMLCanvasElement
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
const nodeVisualBounds: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${id}"]`
|
||||
) as HTMLElement | null
|
||||
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
|
||||
|
||||
if (isLegacyCanvasNodeWithoutVueDom) {
|
||||
const node = window.app!.graph._nodes.find(
|
||||
(n: { id: number | string }) => String(n.id) === id
|
||||
)
|
||||
if (!node) {
|
||||
throw new Error(`Node ${id} not found in graph`)
|
||||
}
|
||||
const rect = node.boundingRect
|
||||
nodeVisualBounds[id] = {
|
||||
x: rect[0],
|
||||
y: rect[1],
|
||||
w: rect[2],
|
||||
h: rect[3]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const domRect = nodeEl.getBoundingClientRect()
|
||||
const footerEls = nodeEl.querySelectorAll(
|
||||
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
|
||||
)
|
||||
let bottom = domRect.bottom
|
||||
for (const footerEl of footerEls) {
|
||||
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
|
||||
}
|
||||
|
||||
nodeVisualBounds[id] = {
|
||||
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
|
||||
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
|
||||
w: domRect.width / ds.scale,
|
||||
h: (bottom - domRect.top) / ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return { selectionBounds, nodeVisualBounds }
|
||||
},
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -79,7 +82,8 @@ export const TestIds = {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
@@ -112,9 +116,7 @@ export const TestIds = {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
formDropdownMenu: 'form-dropdown-menu',
|
||||
formDropdownTrigger: 'form-dropdown-trigger'
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
return {
|
||||
@@ -332,6 +333,22 @@ export class NodeReference {
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
/**
|
||||
* Toggle the node's collapsed state by simulating the same user interaction
|
||||
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
|
||||
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
|
||||
* element with `data-node-id`.
|
||||
*/
|
||||
async toggleCollapse() {
|
||||
const vueLocator = this.comfyPage.page.locator(
|
||||
`[data-node-id="${this.id}"]`
|
||||
)
|
||||
if ((await vueLocator.count()) > 0) {
|
||||
await new VueNodeFixture(vueLocator).toggleCollapse()
|
||||
return
|
||||
}
|
||||
await this.click('collapse')
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
@@ -138,12 +137,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
const menu = comfyPage.page
|
||||
.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
.first()
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await menu.evaluate((el) => {
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
@@ -154,7 +153,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
431
browser_tests/tests/dialogs/managerDialog.spec.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
import type { components as RegistryComponents } from '@comfyorg/registry-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
|
||||
type InstalledPacksResponse =
|
||||
ManagerComponents['schemas']['InstalledPacksResponse']
|
||||
type RegistryNodePack = RegistryComponents['schemas']['Node']
|
||||
|
||||
interface AlgoliaSearchResult {
|
||||
hits: Partial<AlgoliaNodePack>[]
|
||||
nbHits: number
|
||||
page: number
|
||||
nbPages: number
|
||||
hitsPerPage: number
|
||||
}
|
||||
|
||||
interface AlgoliaSearchResponse {
|
||||
results: AlgoliaSearchResult[]
|
||||
}
|
||||
|
||||
const MOCK_PACK_A: RegistryNodePack = {
|
||||
id: 'test-pack-a',
|
||||
name: 'Test Pack A',
|
||||
description: 'A test custom node pack',
|
||||
downloads: 5000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-a',
|
||||
tags: ['image', 'processing']
|
||||
}
|
||||
|
||||
const MOCK_PACK_B: RegistryNodePack = {
|
||||
id: 'test-pack-b',
|
||||
name: 'Test Pack B',
|
||||
description: 'Another test custom node pack for testing search',
|
||||
downloads: 3000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'another-publisher', name: 'Another Publisher' },
|
||||
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-b',
|
||||
tags: ['video', 'generation']
|
||||
}
|
||||
|
||||
const MOCK_PACK_C: RegistryNodePack = {
|
||||
id: 'test-pack-c',
|
||||
name: 'Test Pack C',
|
||||
description: 'Third test pack',
|
||||
downloads: 100,
|
||||
status: 'NodeStatusActive',
|
||||
publisher: { id: 'test-publisher', name: 'Test Publisher' },
|
||||
latest_version: { version: '0.5.0', status: 'NodeVersionStatusActive' },
|
||||
repository: 'https://github.com/test/pack-c'
|
||||
}
|
||||
|
||||
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
|
||||
'test-pack-a': {
|
||||
ver: '1.0.0',
|
||||
cnr_id: 'test-pack-a',
|
||||
enabled: true
|
||||
},
|
||||
'test-pack-c': {
|
||||
ver: '0.5.0',
|
||||
cnr_id: 'test-pack-c',
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-a',
|
||||
id: 'test-pack-a',
|
||||
name: 'Test Pack A',
|
||||
description: 'A test custom node pack',
|
||||
total_install: 5000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'test-publisher',
|
||||
latest_version: '1.0.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-a',
|
||||
comfy_nodes: ['TestNodeA1', 'TestNodeA2'],
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
update_time: '2024-06-01T00:00:00Z',
|
||||
license: 'MIT',
|
||||
tags: ['image', 'processing']
|
||||
}
|
||||
|
||||
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-b',
|
||||
id: 'test-pack-b',
|
||||
name: 'Test Pack B',
|
||||
description: 'Another test custom node pack for testing search',
|
||||
total_install: 3000,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'another-publisher',
|
||||
latest_version: '2.1.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-b',
|
||||
comfy_nodes: ['TestNodeB1'],
|
||||
create_time: '2024-02-01T00:00:00Z',
|
||||
update_time: '2024-07-01T00:00:00Z',
|
||||
license: 'Apache-2.0',
|
||||
tags: ['video', 'generation']
|
||||
}
|
||||
|
||||
const MOCK_HIT_C: Partial<AlgoliaNodePack> = {
|
||||
objectID: 'test-pack-c',
|
||||
id: 'test-pack-c',
|
||||
name: 'Test Pack C',
|
||||
description: 'Third test pack',
|
||||
total_install: 100,
|
||||
status: 'NodeStatusActive',
|
||||
publisher_id: 'test-publisher',
|
||||
latest_version: '0.5.0',
|
||||
latest_version_status: 'NodeVersionStatusActive',
|
||||
repository_url: 'https://github.com/test/pack-c',
|
||||
comfy_nodes: ['TestNodeC1'],
|
||||
create_time: '2024-03-01T00:00:00Z',
|
||||
update_time: '2024-05-01T00:00:00Z',
|
||||
license: 'MIT'
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [MOCK_HIT_A, MOCK_HIT_B, MOCK_HIT_C],
|
||||
nbHits: 3,
|
||||
page: 0,
|
||||
nbPages: 1,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_PACK_B_ONLY: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [MOCK_HIT_B],
|
||||
nbHits: 1,
|
||||
page: 0,
|
||||
nbPages: 1,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MOCK_ALGOLIA_EMPTY: AlgoliaSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
hits: [],
|
||||
nbHits: 0,
|
||||
page: 0,
|
||||
nbPages: 0,
|
||||
hitsPerPage: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test.describe('ManagerDialog', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
const statsWithManager = {
|
||||
...mockSystemStats,
|
||||
system: {
|
||||
...mockSystemStats.system,
|
||||
argv: ['main.py', '--enable-manager']
|
||||
}
|
||||
}
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({ json: statsWithManager })
|
||||
})
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/installed**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/manager/queue/status**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
history: {},
|
||||
running_queue: [],
|
||||
pending_queue: [],
|
||||
installed_packs: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/manager/queue/history**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
|
||||
})
|
||||
|
||||
// Mock Comfy Registry API (fallback when Algolia credentials are unavailable)
|
||||
const registryListResponse = {
|
||||
total: 3,
|
||||
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: registryListResponse })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: registryListResponse })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/getmappings**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/v2/customnode/import_fail_info**',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: {} })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
// Seed manager-ready server feature flags AFTER setup so the WebSocket
|
||||
// feature_flags payload can't overwrite them. mockServerFeatures (on
|
||||
// /api/features) does not populate the serverFeatureFlags ref; direct
|
||||
// reactive-ref mutation is the only reliable approach.
|
||||
// See shareWorkflowDialog.spec.ts:34-48 for the canonical pattern.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
extension: {
|
||||
manager: {
|
||||
supports_v4: true,
|
||||
supports_csrf_post: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function openManagerDialog(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
|
||||
}
|
||||
|
||||
test('Opens the manager dialog via command', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Displays pack cards from search results', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack C')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters displayed packs', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||
})
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
|
||||
})
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
total: 1,
|
||||
nodes: [MOCK_PACK_B],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('Test Pack B')
|
||||
|
||||
await expect(dialog.getByText('Test Pack B')).toBeVisible()
|
||||
await expect(dialog.getByText('Test Pack A')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/test-pack-a',
|
||||
async (route) => {
|
||||
await route.fulfill({ json: MOCK_PACK_A })
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByText('Test Pack A').first().click()
|
||||
|
||||
await expect(dialog.getByText('Test Publisher').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Left side panel navigation tabs exist', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const nav = dialog.locator('nav')
|
||||
await expect(nav.getByText('All Extensions')).toBeVisible()
|
||||
await expect(nav.getByText('Not Installed')).toBeVisible()
|
||||
await expect(nav.getByText('All Installed')).toBeVisible()
|
||||
await expect(nav.getByText('Updates Available')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Switching tabs changes the content view', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const nav = dialog.locator('nav')
|
||||
await nav.getByText('All Installed').click()
|
||||
|
||||
await expect(dialog.getByText('Test Pack A')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Closes via Escape key', async ({ comfyPage }) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('Empty search shows no results message', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||
})
|
||||
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
|
||||
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
|
||||
})
|
||||
await comfyPage.page.route(
|
||||
'**/api.comfy.org/nodes/search**',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
total: 0,
|
||||
nodes: [],
|
||||
page: 1,
|
||||
limit: 64,
|
||||
totalPages: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const searchInput = dialog.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('nonexistent-pack-xyz-999')
|
||||
|
||||
await expect(
|
||||
dialog.getByText(/no results found|try a different search/i).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search mode can be switched between packs and nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openManagerDialog(comfyPage)
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const modeSelector = dialog.getByText('Node Pack').first()
|
||||
await expect(modeSelector).toBeVisible()
|
||||
|
||||
await modeSelector.click()
|
||||
const nodesOption = comfyPage.page.getByRole('option', { name: 'Nodes' })
|
||||
await expect(nodesOption).toBeVisible()
|
||||
await nodesOption.click()
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
expect(switched).toBe(true)
|
||||
})
|
||||
|
||||
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
|
||||
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting(settingId, !initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.LiteGraph!.middle_click_slot_add_default_node
|
||||
)
|
||||
)
|
||||
.toBe(!initialValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -47,11 +48,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
@@ -95,7 +92,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -107,10 +104,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = getOverlay(comfyPage.page)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +175,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -162,7 +187,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
@@ -175,10 +200,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
@@ -189,7 +212,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Count independence from node selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('missing model count stays constant when a node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
54
browser_tests/tests/fileInputReselection.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('File input same-file reselection', () => {
|
||||
test('should allow uploading the same file twice via LoadImage node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const loadImageNode = loadImageNodes[0]
|
||||
const uploadWidget = await loadImageNode.getWidget(1)
|
||||
const fileWidget = await loadImageNode.getWidget(0)
|
||||
|
||||
// First upload
|
||||
const firstUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const firstChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await firstChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await firstUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'First upload should set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
|
||||
// Second upload of the SAME file — before the fix, the hidden input
|
||||
// retained the previous value and onchange did not fire.
|
||||
const secondUpload = comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
const secondChooser = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadWidget.click()
|
||||
await (
|
||||
await secondChooser
|
||||
).setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
await secondUpload
|
||||
|
||||
await expect
|
||||
.poll(() => fileWidget.getValue(), {
|
||||
message: 'Second upload of the same file should still set widget value'
|
||||
})
|
||||
.toContain('test_upload_image')
|
||||
})
|
||||
})
|
||||
@@ -62,4 +62,30 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setFocusMode(true)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Focus mode toggle preserves properties panel width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the properties panel
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
|
||||
// Record the initial panel width
|
||||
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
expect(initialBox).not.toBeNull()
|
||||
const initialWidth = initialBox!.width
|
||||
|
||||
// Toggle focus mode on then off
|
||||
await comfyPage.setFocusMode(true)
|
||||
await comfyPage.setFocusMode(false)
|
||||
|
||||
// Properties panel should be visible again with the same width
|
||||
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
|
||||
return box ? Math.abs(box.width - initialWidth) : Infinity
|
||||
})
|
||||
.toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,3 +95,33 @@ test.describe('Load3D', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Load3D initialization failure', () => {
|
||||
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Force `new THREE.WebGLRenderer(...)` inside Load3d to throw by making
|
||||
// WebGL getContext() calls return null.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const proto = HTMLCanvasElement.prototype as {
|
||||
getContext: (
|
||||
this: HTMLCanvasElement,
|
||||
type: string,
|
||||
options?: unknown
|
||||
) => unknown
|
||||
}
|
||||
const original = proto.getContext
|
||||
proto.getContext = function (type, options) {
|
||||
if (type === 'webgl' || type === 'webgl2') return null
|
||||
return original.call(this, type, options)
|
||||
}
|
||||
})
|
||||
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
|
||||
|
||||
await expect(
|
||||
comfyPage.toast.visibleToasts.filter({
|
||||
hasText: 'Failed to initialize 3D Viewer'
|
||||
})
|
||||
).not.toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Node context menu viewport overflow (#10824)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler toward the lower-left so the menu has limited space below it.
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()!
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height * 0.75
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
test('last menu item "Remove" is reachable via scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
|
||||
{
|
||||
message:
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp',
|
||||
timeout: 3000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// "Remove" is the last item in the More Options menu.
|
||||
// It must become reachable by scrolling the bounded menu list.
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
const didScroll = await rootList.evaluate((el) => {
|
||||
const previousScrollTop = el.scrollTop
|
||||
el.scrollTo({ top: el.scrollHeight })
|
||||
return el.scrollTop > previousScrollTop
|
||||
})
|
||||
expect(didScroll).toBe(true)
|
||||
await expect(removeItem).toBeVisible()
|
||||
})
|
||||
|
||||
test('last menu item "Remove" is clickable and removes the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
await removeItem.scrollIntoViewIfNeeded()
|
||||
await removeItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,162 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
|
||||
if (enabled) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
test.describe(`Ghost node placement (${mode} mode)`, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await setVueMode(comfyPage, mode === 'vue')
|
||||
})
|
||||
|
||||
test('positions ghost node at cursor', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
|
||||
const canvas = window.app!.canvas
|
||||
const rect = canvas.canvas.getBoundingClientRect()
|
||||
const cursorCanvasX =
|
||||
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
|
||||
const cursorCanvasY =
|
||||
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
|
||||
|
||||
return {
|
||||
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
|
||||
diffY: node.pos[1] - 10 - cursorCanvasY
|
||||
}
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test('left-click confirms ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.ghost).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Delete cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Backspace')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('right-click cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function openErrorsTabViaSeeErrors(
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
) {
|
||||
@@ -15,3 +16,30 @@ export async function openErrorsTabViaSeeErrors(
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function openErrorsTab(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await expect(errorsTab).toBeVisible()
|
||||
await errorsTab.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the fake model file from the backend so it is detected as missing.
|
||||
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
|
||||
* during tests — they only serve as metadata for the missing model UI.
|
||||
*/
|
||||
export async function cleanupFakeModel(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
)
|
||||
.toBeTruthy()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -47,7 +47,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
@@ -57,7 +60,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
@@ -68,7 +71,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
@@ -81,7 +87,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -95,7 +104,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
@@ -121,7 +133,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -140,7 +155,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
@@ -154,7 +172,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -15,17 +18,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Should show missing models group in errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
@@ -35,7 +34,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
@@ -46,7 +45,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
@@ -54,7 +53,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
await expect(locateButton.first()).not.toBeVisible()
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
@@ -66,14 +65,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().click()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
@@ -83,7 +82,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Copy URL button for non-asset models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
@@ -94,12 +93,65 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Download button for downloadable models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const downloadButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelDownload
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should clear resolved missing model when Refresh is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await comfyPage.page.route(/\/object_info$/, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
599
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Missing nodes', () => {
|
||||
test('Deleting a missing node removes its error from the errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: on un-bypass, the realtime scan produced a fresh
|
||||
// candidate without url/hash/directory — those fields were only
|
||||
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
|
||||
// row's Copy URL button (v-if gated on representative.url) then
|
||||
// disappeared. Per-node scan now enriches from node.properties.models
|
||||
// which persists across mode toggles. Uses the `_from_node_properties`
|
||||
// fixture because the enrichment source is per-node metadata, not
|
||||
// the workflow-level `models[]` array (which the realtime scan
|
||||
// path does not see).
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a node with missing model increases referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node with missing model removes its error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing media', () => {
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const mediaRows = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: before the execId fix, onNodeRemoved fell back to the
|
||||
// interior node's local id (e.g. "1") when node.graph was already
|
||||
// null, so the error keyed under "2:1" was never removed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: the initial scan pipeline only checked each node's
|
||||
// own mode, so interior nodes of a bypassed subgraph container
|
||||
// surfaced errors even though the container was excluded from
|
||||
// execution. The pipeline now post-filters candidates whose
|
||||
// ancestor path is not fully active.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: useGraphNodeManager replays graph.onNodeAdded for
|
||||
// each interior node on subgraph entry; without an ancestor-aware
|
||||
// guard in scanSingleNodeErrors, that re-scan reintroduced the
|
||||
// error that the initial pipeline had correctly suppressed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflow switching', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Restores missing nodes in errors tab when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
235
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
const REGULAR_ID = '3'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
type Layout = { ref: [number, number]; target: [number, number] }
|
||||
const LAYOUTS: Record<string, Layout> = {
|
||||
'bottom-left': { ref: [200, 100], target: [150, 500] },
|
||||
'bottom-right': { ref: [100, 100], target: [600, 500] }
|
||||
}
|
||||
|
||||
type NodeType = 'subgraph' | 'regular'
|
||||
type NodeState = 'expanded' | 'collapsed'
|
||||
type Position = 'bottom-left' | 'bottom-right'
|
||||
|
||||
function getTargetId(type: NodeType): string {
|
||||
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
|
||||
}
|
||||
|
||||
function getRefId(type: NodeType): string {
|
||||
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
|
||||
}
|
||||
|
||||
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
}
|
||||
|
||||
async function assertSelectionEncompassesNodes(
|
||||
page: Page,
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(2)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await measureSelectionBounds(page, nodeIds)
|
||||
expect(result.selectionBounds).not.toBeNull()
|
||||
|
||||
const sel = result.selectionBounds!
|
||||
const selRight = sel.x + sel.w
|
||||
const selBottom = sel.y + sel.h
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const vis = result.nodeVisualBounds[nodeId]
|
||||
expect(vis).toBeDefined()
|
||||
|
||||
expect(sel.x).toBeLessThanOrEqual(vis.x)
|
||||
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
|
||||
expect(sel.y).toBeLessThanOrEqual(vis.y)
|
||||
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const vueCases: ReadonlyArray<{
|
||||
type: NodeType
|
||||
state: NodeState
|
||||
pos: Position
|
||||
}> = [
|
||||
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
|
||||
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
|
||||
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
|
||||
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
|
||||
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
|
||||
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
|
||||
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
|
||||
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
|
||||
]
|
||||
|
||||
for (const { type, state, pos } of vueCases) {
|
||||
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const targetId = getTargetId(type)
|
||||
const refId = getRefId(type)
|
||||
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[refId]: LAYOUTS[pos].ref,
|
||||
[targetId]: LAYOUTS[pos].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
||||
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
refId,
|
||||
targetId
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('collapsed node narrows bounding box when bypass is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
||||
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
|
||||
test('collapsed node widens bounding box when bypass is added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
||||
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await toggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (legacy mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
|
||||
{ state: 'expanded', pos: 'bottom-left' },
|
||||
{ state: 'expanded', pos: 'bottom-right' },
|
||||
{ state: 'collapsed', pos: 'bottom-left' },
|
||||
{ state: 'collapsed', pos: 'bottom-right' }
|
||||
]
|
||||
|
||||
for (const { state, pos } of legacyCases) {
|
||||
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
|
||||
[REGULAR_ID]: LAYOUTS[pos].target
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await nodeRef.toggleCollapse()
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -17,14 +17,13 @@ test.describe('Workflow sidebar - search', () => {
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json'
|
||||
})
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Search filters saved workflows by name', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
await tab.searchInput.fill('alpha')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
await expect(
|
||||
@@ -34,15 +33,11 @@ test.describe('Workflow sidebar - search', () => {
|
||||
|
||||
test('Clearing search restores all workflows', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('alpha')
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
|
||||
|
||||
await searchInput.fill('')
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
@@ -50,10 +45,8 @@ test.describe('Workflow sidebar - search', () => {
|
||||
|
||||
test('Search with no matches shows empty results', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
|
||||
await searchInput.fill('nonexistent_xyz')
|
||||
await tab.searchInput.fill('nonexistent_xyz')
|
||||
|
||||
await expect(
|
||||
findWorkflow(comfyPage.page, 'alpha-workflow')
|
||||
@@ -62,4 +55,72 @@ test.describe('Workflow sidebar - search', () => {
|
||||
findWorkflow(comfyPage.page, 'beta-workflow')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('deletion', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'alpha-workflow.json': 'default.json',
|
||||
'beta-workflow.json': 'default.json',
|
||||
'gamma-workflow.json': 'default.json'
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Deleting a workflow while search is active removes it from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting during search does not affect other matched results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('workflow')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
|
||||
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
|
||||
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search after deleting during search shows correct workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
|
||||
await tab.searchInput.fill('alpha')
|
||||
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
|
||||
|
||||
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
|
||||
button: 'right'
|
||||
})
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('gamma-workflow')).toBeVisible()
|
||||
await expect(tab.getPersistedItem('alpha-workflow')).toBeHidden()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -232,7 +233,7 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual('workflow1')
|
||||
})
|
||||
|
||||
test('Reports missing nodes warning again when switching back to workflow', async ({
|
||||
test('Restores missing nodes errors silently when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -254,11 +255,17 @@ test.describe('Workflows sidebar', () => {
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow — overlay should reappear
|
||||
// so users can install missing node packs without a page reload
|
||||
// Switch back to the missing_nodes workflow — overlay should NOT
|
||||
// reappear (silent restore), but errors tab should have content
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
// Errors tab should still show missing nodes after silent restore
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
|
||||
@@ -560,6 +560,28 @@ test.describe(
|
||||
})
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
})
|
||||
|
||||
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => getPromotedWidgetCount(comfyPage, '2'),
|
||||
'Primitive widget is restored on load'
|
||||
)
|
||||
.toBe(2)
|
||||
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
const promotedPrimitive = await subgraphNode!.getWidget(1)
|
||||
await expect
|
||||
.poll(
|
||||
() => promotedPrimitive.getValue(),
|
||||
'Primitive widget is not in a disconnected state'
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
@@ -8,6 +9,31 @@ const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -83,5 +109,35 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
290
browser_tests/tests/templateFilteringCount.spec.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
[]
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
|
||||
|
||||
const desktopCard = comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('desktop-hidden')
|
||||
)
|
||||
await expect(desktopCard).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
|
||||
|
||||
const wanDesktopCard = comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('wan-desktop')
|
||||
)
|
||||
await expect(wanDesktopCard).toHaveCount(0)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 2 of 3 templates/i
|
||||
)
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('cloud-visible')
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('desktop-leak-check')
|
||||
)
|
||||
).toHaveCount(0)
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('local-leak-check')
|
||||
)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 3 of 3 templates/i
|
||||
)
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
|
||||
|
||||
const clearButton = comfyPage.templatesDialog.root.getByRole('button', {
|
||||
name: /Clear Filters/i
|
||||
})
|
||||
await clearButton.click()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 2 of 2 templates/i
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -2,6 +2,10 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
|
||||
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
|
||||
|
||||
test.describe('Advanced Widget Visibility', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -21,15 +25,11 @@ test.describe('Advanced Widget Visibility', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getNode(comfyPage: ComfyPage) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
function getWidgets(comfyPage: ComfyPage) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe('Advanced Widget Visibility', () => {
|
||||
).not.toBeVisible()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
@@ -63,20 +63,41 @@ test.describe('Advanced Widget Visibility', () => {
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
await node.getByText(SHOW_ADVANCED_INPUTS).click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await node.getByText(HIDE_ADVANCED_INPUTS).click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should hide advanced footer button while collapsed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
|
||||
const vueNode =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeHidden()
|
||||
|
||||
await vueNode.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(showAdvancedButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -97,6 +118,6 @@ test.describe('Advanced Widget Visibility', () => {
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
|
||||
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
test.describe(
|
||||
'FormDropdown positioning in Vue nodes',
|
||||
{ tag: ['@widget', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('dropdown menu appears directly below the trigger', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const triggerBox = await trigger.first().boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
|
||||
expect(triggerBox).toBeTruthy()
|
||||
expect(menuBox).toBeTruthy()
|
||||
|
||||
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
|
||||
expect(menuBox!.y).toBeGreaterThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height - 5
|
||||
)
|
||||
expect(menuBox!.y).toBeLessThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height + 20
|
||||
)
|
||||
|
||||
// Menu left should be near the trigger left (within 10px tolerance)
|
||||
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
|
||||
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
|
||||
})
|
||||
|
||||
test('dropdown menu appears correctly at different zoom levels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
for (const zoom of [0.75, 1.5]) {
|
||||
// Set zoom via canvas
|
||||
await comfyPage.page.evaluate((scale) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = scale
|
||||
canvas.setDirty(true, true)
|
||||
}, zoom)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.formDropdownMenu
|
||||
)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const triggerBox = await trigger.first().boundingBox()
|
||||
const menuBox = await menu.boundingBox()
|
||||
|
||||
expect(triggerBox).toBeTruthy()
|
||||
expect(menuBox).toBeTruthy()
|
||||
|
||||
// Menu top should still be near trigger bottom regardless of zoom
|
||||
expect(menuBox!.y).toBeGreaterThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height - 5
|
||||
)
|
||||
expect(menuBox!.y).toBeLessThanOrEqual(
|
||||
triggerBox!.y + triggerBox!.height + 20 * zoom
|
||||
)
|
||||
|
||||
// Close dropdown before next iteration
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(menu).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('dropdown closes on outside click', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Click outside the node
|
||||
await comfyPage.page.mouse.click(10, 10)
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dropdown closes on Escape key', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
|
||||
await trigger.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.0",
|
||||
"version": "1.43.16",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -81,6 +81,7 @@
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
@@ -112,6 +113,7 @@
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
|
||||
@@ -73,10 +73,6 @@
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
--color-coral-red-600: #973a40;
|
||||
--color-coral-red-500: #c53f49;
|
||||
--color-coral-red-400: #dd424e;
|
||||
|
||||
--color-bypass: #6a246a;
|
||||
--color-error: #962a2a;
|
||||
|
||||
|
||||
36
pnpm-lock.yaml
generated
@@ -159,6 +159,9 @@ catalogs:
|
||||
'@types/three':
|
||||
specifier: ^0.169.0
|
||||
version: 0.169.0
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -351,6 +354,9 @@ catalogs:
|
||||
unplugin-vue-components:
|
||||
specifier: ^30.0.0
|
||||
version: 30.0.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1
|
||||
vite-plugin-dts:
|
||||
specifier: ^4.5.4
|
||||
version: 4.5.4
|
||||
@@ -488,6 +494,9 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 2.27.2
|
||||
'@vee-validate/zod':
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -581,6 +590,9 @@ importers:
|
||||
typegpu:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.2
|
||||
vee-validate:
|
||||
specifier: 'catalog:'
|
||||
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.13(typescript@5.9.3)
|
||||
@@ -4640,6 +4652,11 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
@@ -9427,6 +9444,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vfile-location@5.0.3:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -13710,6 +13732,14 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -19614,6 +19644,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
vfile-location@5.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -54,6 +54,7 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
@@ -118,6 +119,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.0
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
|
||||
233
scripts/coverage-slack-notify.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
percentage: number
|
||||
totalLines: number
|
||||
coveredLines: number
|
||||
}
|
||||
|
||||
interface SlackBlock {
|
||||
type: 'section'
|
||||
text: {
|
||||
type: 'mrkdwn'
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcovContent(content: string): CoverageData | null {
|
||||
const perFile = new Map<string, { lf: number; lh: number }>()
|
||||
let currentFile = ''
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = line.slice(3)
|
||||
} else if (line.startsWith('LF:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
|
||||
entry.lf = n
|
||||
perFile.set(currentFile, entry)
|
||||
} else if (line.startsWith('LH:')) {
|
||||
const n = parseInt(line.slice(3), 10) || 0
|
||||
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
|
||||
entry.lh = n
|
||||
perFile.set(currentFile, entry)
|
||||
}
|
||||
}
|
||||
|
||||
let totalLines = 0
|
||||
let coveredLines = 0
|
||||
for (const { lf, lh } of perFile.values()) {
|
||||
totalLines += lf
|
||||
coveredLines += lh
|
||||
}
|
||||
|
||||
if (totalLines === 0) return null
|
||||
|
||||
return {
|
||||
percentage: (coveredLines / totalLines) * 100,
|
||||
totalLines,
|
||||
coveredLines
|
||||
}
|
||||
}
|
||||
|
||||
function parseLcov(filePath: string): CoverageData | null {
|
||||
if (!existsSync(filePath)) return null
|
||||
return parseLcovContent(readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function progressBar(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage))
|
||||
const filled = Math.round((clamped / 100) * BAR_WIDTH)
|
||||
const empty = BAR_WIDTH - filled
|
||||
return '█'.repeat(filled) + '░'.repeat(empty)
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return value.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
const prevBucket = Math.floor(prev / MILESTONE_STEP)
|
||||
const currBucket = Math.floor(curr / MILESTONE_STEP)
|
||||
|
||||
if (currBucket > prevBucket) {
|
||||
return currBucket * MILESTONE_STEP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildMilestoneBlock(label: string, milestone: number): SlackBlock {
|
||||
if (milestone >= TARGET) {
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🏆 *GOAL REACHED: ${label} coverage hit ${milestone}%!* 🏆`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% ✅`,
|
||||
'The team did it! 🎊🥳🎉'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = TARGET - milestone
|
||||
return {
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: [
|
||||
`🎉🎉🎉 *MILESTONE: ${label} coverage hit ${milestone}%!*`,
|
||||
`\`${progressBar(milestone)}\` ${milestone}% → ${TARGET}% target`,
|
||||
`${remaining} percentage point${remaining !== 1 ? 's' : ''} to go!`
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prUrl: string
|
||||
prNumber: string
|
||||
author: string
|
||||
} {
|
||||
let prUrl = ''
|
||||
let prNumber = ''
|
||||
let author = ''
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--pr-url=')) prUrl = arg.slice('--pr-url='.length)
|
||||
else if (arg.startsWith('--pr-number='))
|
||||
prNumber = arg.slice('--pr-number='.length)
|
||||
else if (arg.startsWith('--author=')) author = arg.slice('--author='.length)
|
||||
}
|
||||
|
||||
return { prUrl, prNumber, author }
|
||||
}
|
||||
|
||||
function formatCoverageRow(
|
||||
label: string,
|
||||
current: CoverageData,
|
||||
baseline: CoverageData
|
||||
): string {
|
||||
const delta = current.percentage - baseline.percentage
|
||||
return `*${label}:* ${formatPct(baseline.percentage)} → ${formatPct(current.percentage)} (${formatDelta(delta)})`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { prUrl, prNumber, author } = parseArgs(process.argv.slice(2))
|
||||
|
||||
const unitCurrent = parseLcov('coverage/lcov.info')
|
||||
const unitBaseline = parseLcov('temp/coverage-baseline/lcov.info')
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const blocks: SlackBlock[] = []
|
||||
|
||||
const summaryLines: string[] = []
|
||||
summaryLines.push(
|
||||
`✅ *Coverage improved!* — <${prUrl}|PR #${prNumber}> by <https://github.com/${author}|${author}>`
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(unitCurrent.percentage)}\` ${formatPct(unitCurrent.percentage)} unit → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
if (e2eCurrent) {
|
||||
summaryLines.push(
|
||||
`\`${progressBar(e2eCurrent.percentage)}\` ${formatPct(e2eCurrent.percentage)} e2e → ${TARGET}% target`
|
||||
)
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: summaryLines.join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
unitBaseline.percentage,
|
||||
unitCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('Unit test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
const milestone = crossedMilestone(
|
||||
e2eBaseline.percentage,
|
||||
e2eCurrent.percentage
|
||||
)
|
||||
if (milestone !== null) {
|
||||
blocks.push(buildMilestoneBlock('E2E test', milestone))
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { text: 'Coverage improved!', blocks }
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
main()
|
||||
10
src/base/common/selectionBounds.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Padding applied around the selection bounding rect (both screen + canvas). */
|
||||
export const SELECTION_BOUNDS_PADDING = 10
|
||||
|
||||
/** Rectangle expressed in canvas-world coordinates. */
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
@@ -171,14 +171,10 @@ const sidebarPanelVisible = computed(
|
||||
)
|
||||
|
||||
const firstPanelVisible = computed(
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
|
||||
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
|
||||
)
|
||||
const lastPanelVisible = computed(
|
||||
() =>
|
||||
!focusMode.value &&
|
||||
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
|
||||
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -268,6 +264,7 @@ const splitterRefreshKey = computed(() => {
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'left') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -275,6 +272,7 @@ const firstPanelStyle = computed(() => {
|
||||
})
|
||||
|
||||
const lastPanelStyle = computed(() => {
|
||||
if (focusMode.value) return { display: 'none' }
|
||||
if (sidebarLocation.value === 'right') {
|
||||
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
|
||||
}
|
||||
@@ -293,9 +291,13 @@ const lastPanelStyle = computed(() => {
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide sidebar gutter when sidebar is not visible */
|
||||
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
|
||||
/* Hide gutter when adjacent panel is not visible */
|
||||
:deep(
|
||||
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
|
||||
),
|
||||
:deep(
|
||||
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||