Compare commits

..

24 Commits

Author SHA1 Message Date
Jin Yi
07e892bcc5 fix: align teleported dropdown right edge to trigger right edge
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 18:54:33 +09:00
Jin Yi
b77f0551bd fix: align teleported dropdown to trigger left edge instead of right
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 18:00:10 +09:00
Jin Yi
04a172c880 chore: trigger re-review
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:56:44 +09:00
Jin Yi
b29c56dd55 fix: resolve test type errors and remove assertion on closed dropdown
Amp-Thread-ID: https://ampcode.com/threads/T-019d6fd8-61fd-76ed-975a-71e146c1dd5e
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:53:51 +09:00
Jin Yi
8f000fe8da fix: use shared MENU_HEIGHT/MENU_WIDTH constants in FormDropdownMenu
Use the shared constants from types.ts instead of hardcoded Tailwind
classes so dimension changes are reflected in both the menu component
and the positioning logic in FormDropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:46 +09:00
Jin Yi
8618661bab fix: clamp teleported dropdown position to viewport bounds
When neither upward nor downward direction has enough space for the
full menu height, clamp the position so the menu stays within the
viewport instead of overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:46 +09:00
Jin Yi
43a24cc869 fix: prevent teleported dropdown from overflowing viewport top
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d64-af34-7489-abd5-cde23ead7105
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:47:46 +09:00
Jin Yi
3f3d6e4ebe fix: extract MENU_HEIGHT/MENU_WIDTH as shared constants, drop computed for shouldTeleport
Amp-Thread-ID: https://ampcode.com/threads/T-019d2d37-f1a3-7421-90b9-b4d8d058bedb
Co-authored-by: Amp <amp@ampcode.com>
2026-04-09 17:47:46 +09:00
Jin Yi
0a4d3e307b fix: flip teleported dropdown upward when near viewport bottom
Apply the same openUpward logic for both teleported and local cases.
When teleported, use bottom CSS property to open upward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
83da733a5f fix: teleport FormDropdown to body in app mode with bottom-right positioning
Inject OverlayAppendToKey to detect app mode vs canvas. In app mode,
use Teleport to body with position:fixed at the trigger's bottom-right
corner, clamped to viewport bounds. In canvas, keep local absolute
positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
eed1fbbb8a Revert "fix: restore teleport for FormDropdown in app mode"
This reverts commit 8a88e40c40eb9f298c160acde63464aaad49318c.
2026-04-09 17:47:45 +09:00
Jin Yi
c514b6a825 fix: restore teleport for FormDropdown in app mode
Inject OverlayAppendToKey to detect app mode ('body') vs canvas
('self'). In app mode, use <Teleport to="body"> with position:fixed
to escape overflow-hidden/overflow-y-auto ancestors. In canvas, keep
local absolute positioning for correct zoom scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
88dfe6d749 fix: prefer direction with more available space for dropdown
Compare space above vs below the trigger and open toward whichever
side has more room. Prevents flipping upward when the menu would
overflow even more in that direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
e98df0a577 fix: flip dropdown upward when near viewport bottom
Use getBoundingClientRect() only for direction detection (not
positioning), so it works safely even inside CSS transform chains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
15226f7730 fix: stabilize E2E tests for FormDropdown positioning
- Replace fragile CSS selectors with data-testid for trigger button
- Update appModeDropdownClipping to use getByTestId after Popover removal
- Change zoom test from 0.5 to 0.75 to avoid too-small click targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:45 +09:00
Jin Yi
b495372511 fix: formdropdown position 2026-04-09 17:47:45 +09:00
jaeone94
809da9c11c fix: use cloud assets for asset widget default value (#10983)
## Summary

In cloud mode, asset-supported nodes (e.g. CheckpointLoaderSimple) used
the server's `object_info` combo options as their default widget value.
These options list local files on the backend which may not exist in the
user's cloud asset library. When the missing-model pipeline runs (on
undo, reload, or tab switch), it checks widget values against cloud
assets and correctly flags these local-only files as missing — producing
errors that appear to be false positives but are actually valid
detections of unusable defaults.

This PR changes the default value source from server combo options to
the cloud assets store.

## Default Value Behavior (Before → After)

### Cloud + asset-supported widgets (changed)

| Condition | Before | After |
|-----------|--------|-------|
| Assets cached, `inputSpec.default` in assets | `inputSpec.default` |
`inputSpec.default` |
| Assets cached, `inputSpec.default` not in assets | `inputSpec.default`
| `assets[0]` |
| Assets cached, no `inputSpec.default`, `options` exist | `options[0]`
| `assets[0]` |
| Assets not cached, `inputSpec.default` exists | `inputSpec.default` |
`undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, `options` exist |
`options[0]` | `undefined` → "Select model" |
| Assets not cached, no `inputSpec.default`, no `options` | `undefined`
→ "Select model" | `undefined` → "Select model" |

### Cloud + non-asset widgets (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

### OSS (unchanged)

| Condition | Behavior |
|-----------|----------|
| `inputSpec.default` exists | `inputSpec.default` |
| `options` exist | `options[0]` |
| `remote` input | `"Loading..."` |
| None | `undefined` |

## Root Cause

1. `addComboWidget` called `getDefaultValue(inputSpec)` which returns
`inputSpec.options[0]` — a local file from `object_info`
2. In cloud mode, `shouldUseAssetBrowser()` creates an asset widget with
this local filename as default
3. The model (e.g.
`dynamicrafter/controlnet/dc-sketch_encoder_fp16.safetensors`) exists on
the server but not in the user's cloud asset library
4. On undo/reload, `verifyAssetSupportedCandidates()` checks the widget
value against cloud assets → not found → marked as missing

## Changes

### Production (`useComboWidget.ts`)
- New `resolveCloudDefault(nodeType, specDefault)` function encapsulates
cloud default resolution
- Default priority: `inputSpec.default` (if found in cloud assets) →
first cloud asset → `undefined` (shows "Select model" placeholder)
- Edge case guards: `!= null` check for falsy defaults, `|| undefined`
for empty `getAssetFilename` return
- Server combo options (`object_info`) are no longer used as defaults
for asset widgets

### Unit Tests (`useComboWidget.test.ts`)
- 6 scenarios covering all default value paths:
  - Cloud assets loaded, no `inputSpec.default` → `assets[0]`
  - Cloud assets loaded, `inputSpec.default` in assets → uses default
  - Cloud assets loaded, `inputSpec.default` not in assets → `assets[0]`
  - No cloud assets, with `inputSpec.default` → placeholder
  - No cloud assets, with server options → placeholder
  - Asset widget creation verification
- Test helper refactored: assertions moved from helper to each test for
clarity

### E2E Test (`cloud-asset-default.spec.ts`)
- New `@cloud` tagged test verifying CheckpointLoaderSimple uses first
cloud asset, not server default
- Fixture extension stubs `/api/assets` before app loads (local backend
returns 503 for this endpoint)
- Uses typed mock data from existing `assetFixtures.ts`

## Scope

- **Cloud only**: All changes gated behind `isCloud` +
`shouldUseAssetBrowser()`
- **OSS impact**: None — code path is not entered in non-cloud builds
- **Breaking changes**: None — `useComboWidget` export signature
unchanged

## Review Focus
- Should the `/api/assets` stub in the E2E fixture extension be moved
into `ComfyPage` for all `@cloud` tests?

## Record
Before 


https://github.com/user-attachments/assets/994162a0-b56a-4e84-9b1c-d0f0068196d5



After


https://github.com/user-attachments/assets/ba299990-9bd3-4565-bd09-bffac3db60a9
2026-04-09 15:44:04 +09:00
Dante
65d1313443 fix: preserve CustomCombo options through clone and paste (#10853)
## Summary

- Fix `CustomCombo` copy/paste so the combo keeps its option list and
selected value
- Scope the fix to `src/extensions/core/customWidgets.ts` instead of
changing LiteGraph core deserialization
- Replace the previous round-trip test with a regression test that
exercises the actual clone/paste lifecycle

- Fixes #9927

## Root Cause

`CustomCombo` option widgets override `value` to read from
`widgetValueStore`.
During `node.clone()` and clipboard paste, `configure()` restores widget
values before the new node is added to the graph and before those
widgets are registered in the store.
That meant the option widgets read back as empty while `updateCombo()`
was rebuilding the combo state, so `comboWidget.options.values` became
blank on the pasted node.

## Fix

Keep a local fallback value for each generated `option*` widget in
`customWidgets.ts`.
The getter now returns the store-backed value when available and falls
back to the locally restored value before store registration.
This preserves the option list during `clone().serialize()` and paste
without hard-coding `CustomCombo` behavior into
`LGraphNode.configure()`.

## Why No E2E Test

This regression happens in the internal LiteGraph clipboard lifecycle:
`clone() -> serialize() -> createNode() -> configure() -> graph.add()`.
The failing state is the transient pre-add relationship between
`CustomCombo`'s store-backed option widgets and
`comboWidget.options.values`, which is not directly exposed through a
stable DOM assertion in the current Playwright suite.
A focused unit regression test is the most direct way to cover that
lifecycle without depending on brittle canvas interaction timing.

## Test Plan

- [x] Regression test covers `clone().serialize() -> createNode() ->
configure() -> graph.add()` for `CustomCombo`
- [ ] CI on the latest two commits (`81ac6d2ce`, `94147caf1`)
- [ ] Manual: create `CustomCombo` -> add `alpha`, `beta`, `gamma` ->
select `beta` -> copy/paste -> verify the pasted combo still shows all
three options and keeps `beta` selected
2026-04-09 12:35:20 +09:00
Alexander Brown
f90d6cf607 test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#10965)
## Summary

Migrate 132 test files from `@vue/test-utils` (VTU) to
`@testing-library/vue` (VTL) with `@testing-library/user-event`,
adopting user-centric behavioral testing patterns across the codebase.

## Changes

- **What**: Systematic migration of component/unit tests from VTU's
`mount`/`wrapper` API to VTL's `render`/`screen`/`userEvent` API across
132 files in `src/`
- **Breaking**: None — test-only changes, no production code affected

### Migration breakdown

| Batch | Files | Description |
|-------|-------|-------------|
| 1 | 19 | Simple render/assert tests |
| 2A | 16 | Interactive tests with user events |
| 2B-1 | 14 | Interactive tests (continued) |
| 2B-2 | 32 | Interactive tests (continued) |
| 3A–3E | 51 | Complex tests (stores, composables, heavy mocking) |
| Lint fix | 7 | `await` on `fireEvent` calls for `no-floating-promises`
|
| Review fixes | 15 | Address CodeRabbit feedback (3 rounds) |

### Review feedback addressed

- Removed class-based assertions (`text-ellipsis`, `pr-3`, `.pi-save`,
`.skeleton`, `.bg-black\/15`, Tailwind utilities) in favor of
behavioral/accessible queries
- Added null guards before `querySelector` casts
- Added `expect(roots).toHaveLength(N)` guards before indexed NodeList
access
- Wrapped fake timer tests in `try/finally` for guaranteed cleanup
- Split double-render tests into focused single-render tests
- Replaced CSS class selectors with
`screen.getByText`/`screen.getByRole` queries
- Updated stubs to use semantic `role`/`aria-label` instead of CSS
classes
- Consolidated redundant edge-case tests
- Removed manual `document.body.appendChild` in favor of VTL container
management
- Used distinct mock return values to verify command wiring

### VTU holdouts (2 files)

These files intentionally retain `@vue/test-utils` because their
components use `<script setup>` without `defineExpose`, making internal
computed properties and methods inaccessible via VTL:

1. **`NodeWidgets.test.ts`** — partial VTU for `vm.processedWidgets`
2. **`WidgetSelectDropdown.test.ts`** — full VTU for heavy
`wrapper.vm.*` access

## Follow-up

Deferred items (`ComponentProps` typing, camelCase listener props)
tracked in #10966.

## Review Focus

- Test correctness: all migrated tests preserve original behavioral
coverage
- VTL idioms: proper use of `screen` queries, `userEvent`, and
accessibility-based selectors
- The 2 VTU holdout files are intentional, not oversights

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10965-test-migrate-132-test-files-from-vue-test-utils-to-testing-library-vue-33c6d73d36508199a6a7e513cf5d8296)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 19:21:42 -07:00
Christian Byrne
2c34d955cb feat(website): add zh-CN translations for homepage and secondary pages (#10157)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10157-feat-website-add-zh-CN-translations-for-homepage-and-secondary-pages-3266d73d3650811f918cc35eca62a4bc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 19:18:19 -07:00
Christian Byrne
8b6c1b3649 refactor: consolidate SubscriptionTier type (#10487)
## Summary

Consolidate the `SubscriptionTier` type from 3 independent definitions
into a single source of truth in `tierPricing.ts`.

## Changes

- **What**: Exported `SubscriptionTier` from `tierPricing.ts`. Removed
hand-written unions from `workspaceApi.ts` (lines 80-88),
`PricingTable.vue`, and `PricingTableWorkspace.vue`. All now import from
the canonical location.
- **Files**: 4 files changed (type-only, ~5 net lines)

## Review Focus

- This is a type-only change — `pnpm typecheck` is the primary
validation
- If the OpenAPI schema ever adds tiers, there is now one place to
update

## Stack

PR 5/5: #10483#10484#10485#10486 → **→ This PR**
2026-04-08 19:17:44 -07:00
Christian Byrne
026aeb71b2 refactor: decompose MembersPanelContent into focused components (#10486)
## Summary

Decompose the 562-line `MembersPanelContent.vue` into focused
single-responsibility components.

## Changes

- **What**: Extracted `RoleBadge.vue`, `MemberListItem.vue`,
`PendingInvitesList.vue`, and `MemberUpsellBanner.vue` from
`MembersPanelContent.vue`. Added `RoleBadge.test.ts`. The parent
component is slimmed from 562 → ~120 lines.
- **Files**: 6 files changed (4 new components + 1 new test + 1
refactored)

## Review Focus

- Component boundaries — each extracted component has a clear single
responsibility
- `MembersPanelContent.vue` still orchestrates all behavior; extracted
components are presentational
- Visual QA needed: workspace settings panel should look and behave
identically

## Stack

PR 4/5: #10483#10484#10485 → **→ This PR** → #10487

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-08 18:57:11 -07:00
Alexander Brown
d96a7d2b32 fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#10973)
## Changes

- Fix unsafe optional chaining warnings in 2 test files
- Promote `no-unsafe-optional-chaining` to error in oxlintrc
- Remove stale knip ignores (useGLSLRenderer, website deps, astro entry)
- Remove `vue/no-dupe-keys` from oxlintrc (removed from oxlint vue
plugin; `eslint/no-dupe-keys` covers it)
- Un-export unused `UniformSource`/`UniformSources` interfaces
- Dedupe pnpm lockfile

## Dependency Upgrades

| Package | Before | After |
|---------|--------|-------|
| knip | 6.0.1 | 6.3.1 |
| oxlint | 1.55.0 | 1.59.0 |
| oxfmt | 0.40.0 | 0.44.0 |
| eslint-plugin-oxlint | 1.55.0 | 1.59.0 |
| oxlint-tsgolint | 0.17.0 | 0.20.0 |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10973-fix-resolve-lint-knip-warnings-and-upgrade-oxlint-oxfmt-knip-33c6d73d36508135a773f0a174471cf9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 18:30:37 -07:00
Comfy Org PR Bot
1720aa0286 1.44.0 (#10974)
Minor version increment to 1.44.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10974-1-44-0-33c6d73d365081d98a3bd646d3374b3b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-08 18:13:31 -07:00
329 changed files with 3743 additions and 19902 deletions

View File

@@ -64,16 +64,15 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: actions/download-artifact@v7
with:
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && hashFiles('reports/**') != ''
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -7,6 +7,7 @@ on:
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
workflow_dispatch:
concurrency:
@@ -14,36 +15,7 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
steps:
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
setup:
needs: changes
if: ${{ needs.changes.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -181,9 +153,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -212,35 +184,14 @@ jobs:
path: ./playwright-report/
retention-days: 30
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: >-
${{
needs.changes.outputs.should_run == 'true' &&
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
permissions:
pull-requests: write
steps:
@@ -259,15 +210,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: >-
${{
always() &&
needs.changes.outputs.should_run == 'true' &&
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
permissions:
pull-requests: write
contents: read

View File

@@ -1,90 +0,0 @@
---
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"

View File

@@ -1,74 +0,0 @@
---
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 }}

View File

@@ -318,9 +318,6 @@ 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

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
const features = [
{ icon: '📚', label: 'Guided Tutorials' },
{ icon: '🎥', label: 'Video Courses' },
{ icon: '🛠️', label: 'Hands-on Projects' }
]
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) }
])
</script>
<template>
@@ -13,14 +19,15 @@ const features = [
<span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
>
COMFY ACADEMY
{{ t('academy.badge', locale) }}
</span>
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
<h2 class="mt-6 text-3xl font-bold text-white">
{{ t('academy.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Learn to build professional AI workflows with guided tutorials, video
courses, and hands-on projects.
{{ t('academy.body', locale) }}
</p>
<!-- Feature bullets -->
@@ -40,7 +47,7 @@ const features = [
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"
>
EXPLORE ACADEMY
{{ t('academy.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,37 +1,43 @@
<script setup lang="ts">
const cards = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards = computed(() => [
{
icon: '🖥️',
title: 'Comfy Desktop',
description: 'Full power on your local machine. Free and open source.',
cta: 'DOWNLOAD',
title: t('cta.desktop.title', locale),
description: t('cta.desktop.desc', locale),
cta: t('cta.desktop.cta', locale),
href: '/download',
outlined: false
},
{
icon: '☁️',
title: 'Comfy Cloud',
description: 'Run workflows in the cloud. No GPU required.',
cta: 'TRY CLOUD',
title: t('cta.cloud.title', locale),
description: t('cta.cloud.desc', locale),
cta: t('cta.cloud.cta', locale),
href: 'https://app.comfy.org',
outlined: false
},
{
icon: '⚡',
title: 'Comfy API',
description: 'Integrate AI generation into your applications.',
cta: 'VIEW DOCS',
title: t('cta.api.title', locale),
description: t('cta.api.desc', locale),
cta: t('cta.api.cta', locale),
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">
Choose Your Way to Comfy
{{ t('cta.heading', locale) }}
</h2>
<!-- CTA cards -->

View File

@@ -1,30 +1,37 @@
<script setup lang="ts">
const steps = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = computed(() => [
{
number: '1',
title: 'Download & Sign Up',
description: 'Get Comfy Desktop for free or create a Cloud account'
title: t('getStarted.step1.title', locale),
description: t('getStarted.step1.desc', locale)
},
{
number: '2',
title: 'Load a Workflow',
description:
'Choose from thousands of community workflows or build your own'
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.desc', locale)
},
{
number: '3',
title: 'Generate',
description: 'Hit run and watch your AI workflow come to life'
title: t('getStarted.step3.title', locale),
description: t('getStarted.step3.desc', locale)
}
]
])
</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">Get Started in Minutes</h2>
<h2 class="text-3xl font-bold text-white">
{{ t('getStarted.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
From download to your first AI-generated output in three simple steps
{{ t('getStarted.subheading', locale) }}
</p>
<!-- Steps -->
@@ -55,7 +62,7 @@ const steps = [
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"
>
DOWNLOAD COMFY
{{ t('getStarted.cta', locale) }}
</a>
</div>
</section>

View File

@@ -1,16 +1,23 @@
<script setup lang="ts">
const ctaButtons = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctaButtons = computed(() => [
{
label: 'GET STARTED',
label: t('hero.cta.getStarted', locale),
href: 'https://app.comfy.org',
variant: 'solid' as const
},
{
label: 'LEARN MORE',
label: t('hero.cta.learnMore', locale),
href: '/about',
variant: 'outline' as const
}
]
])
</script>
<template>
@@ -39,12 +46,11 @@ const ctaButtons = [
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
>
Professional Control of Visual AI
{{ t('hero.headline', locale) }}
</h1>
<p class="mt-6 max-w-lg text-lg text-smoke-700">
Comfy is the AI creation engine for visual professionals who demand
control over every model, every parameter, and every output.
{{ t('hero.subheadline', locale) }}
</p>
<div class="mt-8 flex flex-wrap gap-4">

View File

@@ -1,3 +1,10 @@
<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">
@@ -7,13 +14,11 @@
</span>
<h2 class="text-4xl font-bold text-white md:text-5xl">
Method, Not Magic
{{ t('manifesto.heading', locale) }}
</h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
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.
{{ t('manifesto.body', locale) }}
</p>
<!-- Separator line -->

View File

@@ -1,6 +1,16 @@
<!-- TODO: Replace with actual workflow demo content -->
<script setup lang="ts">
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
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)
])
</script>
<template>
@@ -8,9 +18,11 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
<div class="mx-auto max-w-7xl px-6">
<!-- Section header -->
<div class="text-center">
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
<h2 class="text-3xl font-bold text-white">
{{ t('showcase.heading', locale) }}
</h2>
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
Watch how professionals build AI workflows with unprecedented control
{{ t('showcase.subheading', locale) }}
</p>
</div>
@@ -28,7 +40,9 @@ const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
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">Workflow Demo Coming Soon</p>
<p class="text-sm text-smoke-700">
{{ t('showcase.placeholder', locale) }}
</p>
</div>
</div>

View File

@@ -1,39 +1,73 @@
<script setup lang="ts">
const columns = [
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(() => [
{
title: 'Product',
title: t('footer.product', locale),
links: [
{ label: 'Comfy Desktop', href: '/download' },
{ label: 'Comfy Cloud', href: 'https://app.comfy.org' },
{ label: 'ComfyHub', href: 'https://hub.comfy.org' },
{ label: 'Pricing', href: '/pricing' }
{
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)
}
]
},
{
title: 'Resources',
title: t('footer.resources', locale),
links: [
{ 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' }
{
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'
}
]
},
{
title: 'Company',
title: t('footer.company', locale),
links: [
{ label: 'About', href: '/about' },
{ label: 'Careers', href: '/careers' },
{ label: 'Enterprise', href: '/enterprise' }
{ 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)
}
]
},
{
title: 'Legal',
title: t('footer.legal', locale),
links: [
{ label: 'Terms of Service', href: '/terms-of-service' },
{ label: 'Privacy Policy', href: '/privacy-policy' }
{
label: t('footer.terms', locale),
href: localePath('/terms-of-service', locale)
},
{
label: t('footer.privacy', locale),
href: localePath('/privacy-policy', locale)
}
]
}
]
])
const socials = [
{
@@ -76,11 +110,16 @@ const socials = [
>
<!-- Brand -->
<div class="lg:col-span-1">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
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">
Professional control of visual AI.
{{ t('footer.tagline', locale) }}
</p>
</div>
@@ -113,7 +152,8 @@ 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">
&copy; {{ new Date().getFullYear() }} Comfy Org. All rights reserved.
&copy; {{ new Date().getFullYear() }}
{{ t('footer.copyright', locale) }}
</p>
<!-- Social icons -->

View File

@@ -1,15 +1,23 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
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 }>()
const mobileMenuOpen = ref(false)
const currentPath = ref('')
const navLinks = [
{ label: 'ENTERPRISE', href: '/enterprise' },
{ label: 'GALLERY', href: '/gallery' },
{ label: 'ABOUT', href: '/about' },
{ label: 'CAREERS', href: '/careers' }
]
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 ctaLinks = [
{
@@ -49,14 +57,19 @@ onUnmounted(() => {
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
:aria-label="t('nav.ariaLabel', locale)"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<a
:href="localePath('/', locale)"
class="text-2xl font-bold text-brand-yellow italic"
>
Comfy
</a>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<!-- Desktop nav links -->
<div class="hidden items-center gap-8 md:flex">
@@ -77,8 +90,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? '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'
? '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'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
@@ -90,7 +103,7 @@ onUnmounted(() => {
<!-- Mobile hamburger -->
<button
class="flex flex-col gap-1.5 md:hidden"
aria-label="Toggle menu"
:aria-label="t('nav.toggleMenu', locale)"
aria-controls="site-mobile-menu"
:aria-expanded="mobileMenuOpen"
@click="mobileMenuOpen = !mobileMenuOpen"
@@ -135,8 +148,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? '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'
? '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'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>

View File

@@ -1,4 +1,10 @@
<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',
@@ -14,11 +20,11 @@ const logos = [
'EA'
]
const metrics = [
{ value: '60K+', label: 'Custom Nodes' },
{ value: '106K+', label: 'GitHub Stars' },
{ value: '500K+', label: 'Community Members' }
]
const metrics = computed(() => [
{ value: '60K+', label: t('social.customNodes', locale) },
{ value: '106K+', label: t('social.githubStars', locale) },
{ value: '500K+', label: t('social.communityMembers', locale) }
])
</script>
<template>
@@ -28,7 +34,7 @@ const metrics = [
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
>
Trusted by Industry Leaders
{{ t('social.heading', locale) }}
</p>
<!-- Logo row -->

View File

@@ -1,9 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const activeFilter = ref('All')
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
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 testimonials = [
{
@@ -12,7 +31,7 @@ const testimonials = [
name: 'Sarah Chen',
title: 'Lead Technical Artist',
company: 'Studio Alpha',
industry: 'VFX'
industry: 'VFX' as const
},
{
quote:
@@ -20,7 +39,7 @@ const testimonials = [
name: 'Marcus Rivera',
title: 'Creative Director',
company: 'PixelForge',
industry: 'Gaming'
industry: 'Gaming' as const
},
{
quote:
@@ -28,7 +47,7 @@ const testimonials = [
name: 'Yuki Tanaka',
title: 'Head of AI',
company: 'CreativeX',
industry: 'Advertising'
industry: 'Advertising' as const
}
]
@@ -42,13 +61,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">
What Professionals Say
{{ t('testimonials.heading', locale) }}
</h2>
<!-- Industry filter pills -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
v-for="industry in industries"
v-for="industry in industryKeys"
:key="industry"
type="button"
:aria-pressed="activeFilter === industry"
@@ -60,7 +79,7 @@ const filteredTestimonials = computed(() => {
"
@click="activeFilter = industry"
>
{{ industry }}
{{ industryLabels[industry] }}
</button>
</div>
@@ -85,7 +104,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"
>
{{ testimonial.industry }}
{{ industryLabels[testimonial.industry] ?? testimonial.industry }}
</span>
</article>
</div>

View File

@@ -1,14 +1,18 @@
<!-- TODO: Wire category content swap when final assets arrive -->
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const categories = [
'VFX & Animation',
'Creative Agencies',
'Gaming',
'eCommerce & Fashion',
'Community & Hobbyists'
]
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 activeCategory = ref(0)
</script>
@@ -27,7 +31,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">
Built for Every Creative Industry
{{ t('useCase.heading', locale) }}
</h2>
<nav
@@ -52,15 +56,14 @@ const activeCategory = ref(0)
</nav>
<p class="mt-10 max-w-lg text-smoke-700">
Powered by 60,000+ nodes, thousands of workflows, and a community
that builds faster than any one company could.
{{ t('useCase.body', locale) }}
</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"
>
EXPLORE WORKFLOWS
{{ t('useCase.cta', locale) }}
</a>
</div>

View File

@@ -1,34 +1,37 @@
<script setup lang="ts">
const pillars = [
import { computed } from 'vue'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const pillars = computed(() => [
{
icon: '⚡',
title: 'Build',
description:
'Design complex AI workflows visually with our node-based editor'
title: t('pillars.buildTitle', locale),
description: t('pillars.buildDesc', locale)
},
{
icon: '🎨',
title: 'Customize',
description: 'Fine-tune every parameter across any model architecture'
title: t('pillars.customizeTitle', locale),
description: t('pillars.customizeDesc', locale)
},
{
icon: '🔧',
title: 'Refine',
description:
'Iterate on outputs with precision controls and real-time preview'
title: t('pillars.refineTitle', locale),
description: t('pillars.refineDesc', locale)
},
{
icon: '⚙️',
title: 'Automate',
description:
'Scale your workflows with batch processing and API integration'
title: t('pillars.automateTitle', locale),
description: t('pillars.automateDesc', locale)
},
{
icon: '🚀',
title: 'Run',
description: 'Deploy locally or in the cloud with identical results'
title: t('pillars.runTitle', locale),
description: t('pillars.runDesc', locale)
}
]
])
</script>
<template>
@@ -36,10 +39,10 @@ const pillars = [
<div class="mx-auto max-w-7xl">
<header class="mb-16 text-center">
<h2 class="text-3xl font-bold text-white md:text-4xl">
The Building Blocks of AI Production
{{ t('pillars.heading', locale) }}
</h2>
<p class="mt-4 text-smoke-700">
Five powerful capabilities that give you complete control
{{ t('pillars.subheading', locale) }}
</p>
</header>

View File

@@ -0,0 +1,253 @@
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 }

View File

@@ -4,89 +4,89 @@ import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const team = [
{ 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' },
{ 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' },
]
const collaborators = [
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
{ name: 'Yogo', role: '协作者' },
{ name: 'Fill (Machine Delusions)', role: '协作者' },
{ name: 'Julien (MJM)', role: '协作者' },
]
const projects = [
{ 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.' },
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
{ name: 'Docs', description: '官方文档、指南和教程。' },
]
const faqs = [
{
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 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
},
{
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: '谁在开发 ComfyUI',
a: 'ComfyUI comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
},
{
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: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
},
{
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.',
q: '未来有什么计划?',
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
},
]
---
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<SiteNav locale="zh-CN" 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">
An open-source community and company building the most powerful tools for generative AI creators.
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
</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">Our Mission</h2>
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
We want to build the operating system for Gen AI.
我们想打造生成式 AI 的操作系统。
</p>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
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.
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
从图像和视频合成到音频生成ComfyUI 提供了一个模块化的
节点式环境,让专业人士和爱好者可以创建、迭代
和部署生产级工作流——没有黑箱。
</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">What Do We Do?</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</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,24 +98,23 @@ 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">Who We Are</h2>
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
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.
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
创作者、开发者和研究者社区。今天Comfy Org 是一个位于旧金山的小型扁平化团队,
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
他们构建自定义节点、分享工作流,并不断突破生成式 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">Team</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</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">
@@ -128,10 +127,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">Collaborators</h2>
<h2 class="text-2xl font-bold">协作者</h2>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
@@ -143,10 +142,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">FAQs</h2>
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
@@ -158,19 +157,19 @@ const faqs = [
</div>
</section>
<!-- Join Our Team CTA -->
<!-- 加入我们 CTA -->
<section class="bg-charcoal-800 px-6 py-24 text-center">
<h2 class="text-3xl font-bold md:text-4xl">Join Our Team</h2>
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
We're looking for people who are passionate about open-source, generative AI, and building great developer tools.
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
</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 />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -78,7 +78,7 @@ const questions = [
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
@@ -196,5 +196,5 @@ const questions = [
</div>
</section>
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -32,7 +32,7 @@ const cards = [
---
<BaseLayout title="下载 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" 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 />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="作品集 — Comfy">
<SiteNav client:load />
<SiteNav locale="zh-CN" 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 />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -16,19 +16,19 @@ import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<SiteNav client:load />
<SiteNav locale="zh-CN" client:load />
<main>
<HeroSection />
<SocialProofBar />
<ProductShowcase />
<ValuePillars />
<UseCaseSection client:visible />
<CaseStudySpotlight />
<TestimonialsSection client:visible />
<GetStartedSection />
<CTASection />
<ManifestoSection />
<AcademySection />
<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" />
</main>
<SiteFooter />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" 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 />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -9,7 +9,7 @@ import SiteFooter from '../../components/SiteFooter.vue'
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<SiteNav client:load />
<SiteNav locale="zh-CN" 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 />
<SiteFooter locale="zh-CN" />
</BaseLayout>

View File

@@ -4,9 +4,6 @@
"outputDirectory": "apps/website/dist",
"installCommand": "pnpm install --frozen-lockfile",
"framework": null,
"github": {
"enabled": false
},
"redirects": [
{
"source": "/pricing",

View File

@@ -1,34 +0,0 @@
{
"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
}

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
@@ -31,7 +31,7 @@
{
"id": 11,
"type": "LoadImage",
"pos": [450, 200],
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,

View File

@@ -1,27 +1,7 @@
{
"last_node_id": 1,
"last_node_id": 0,
"last_link_id": 0,
"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"]
}
],
"nodes": [],
"links": [],
"groups": [],
"config": {},
@@ -35,7 +15,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
],
"version": 0.4

View File

@@ -1,42 +0,0 @@
{
"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
}

View File

@@ -1,66 +0,0 @@
{
"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
}

View File

@@ -34,7 +34,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
]
},

View File

@@ -1,141 +0,0 @@
{
"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
}

View File

@@ -1,141 +0,0 @@
{
"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
}

View File

@@ -78,7 +78,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
"directory": "text_encoders"
}
],
"version": 0.4

View File

@@ -1,116 +0,0 @@
{
"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
}

View File

@@ -1,197 +0,0 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [627.5973510742188, 423.0972900390625],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [554.8743286132812, 100.95539093017578],
"size": [270, 262],
"flags": { "collapsed": true },
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [685.1265869140625, 439.1734619140625],
"size": [140, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [4]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [58.7671207025881, 137.7124650620126]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -1,179 +0,0 @@
{
"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
}

View File

@@ -2,13 +2,9 @@ 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 {
@@ -20,10 +16,4 @@ 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')
}
}

View File

@@ -1,28 +0,0 @@
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
}
]
}

View File

@@ -215,12 +215,11 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -297,29 +296,15 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -85,20 +20,43 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -1,10 +1,7 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { 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'
@@ -117,27 +114,6 @@ 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,
@@ -212,13 +188,3 @@ 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
}
}

View File

@@ -1,95 +0,0 @@
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>
}

View File

@@ -55,9 +55,6 @@ 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',
@@ -82,8 +79,7 @@ export const TestIds = {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
@@ -116,7 +112,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu',
formDropdownTrigger: 'form-dropdown-trigger'
},
builder: {
footerNav: 'builder-footer-nav',

View File

@@ -5,7 +5,6 @@ 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 {
@@ -333,22 +332,6 @@ 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
}

View File

@@ -4,6 +4,7 @@ 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.
@@ -137,12 +138,12 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// 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 menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
.first()
await expect(menu).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const isInViewport = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -153,7 +154,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -133,30 +133,6 @@ test.describe('AssetHelper', () => {
await assetApi.clearMocks()
})
test('GET /assets filters by exclude_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('should use first cloud asset when server default is not in assets', async ({
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
{ timeout: 10_000 }
)
.toBe('asset')
// Add a new CheckpointLoaderSimple — should use first cloud asset,
// not the server's object_info default.
const widgetValue = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
const widget = node!.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
})
// Production resolves via getAssetFilename (user_metadata.filename →
// metadata.filename → asset.name). Test fixtures have no metadata
// filename, so asset.name is the resolved value.
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
})
})

View File

@@ -66,30 +66,3 @@ test.describe(
})
}
)
test.describe(
'Collapsed node links inside subgraph on first entry',
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
() => {
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-collapsed-node'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// fitView runs on first entry and re-syncs slot layouts for the
// pre-collapsed KSampler. Screenshot captures the rendered canvas
// links to guard against regressing the stale-coordinate bug.
await expect(comfyPage.canvas).toHaveScreenshot(
'subgraph-entry-collapsed-node-links.png'
)
})
}
)

View File

@@ -1,431 +0,0 @@
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()
})
})

View File

@@ -131,38 +131,6 @@ 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
}) => {

View File

@@ -5,7 +5,6 @@ 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 }) => {
@@ -48,7 +47,11 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
await cleanupFakeModel(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 comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -92,7 +95,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -104,37 +107,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).toBeHidden()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
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()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})
@@ -175,7 +151,6 @@ 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()
})
@@ -187,7 +162,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -200,8 +175,10 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -212,37 +189,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
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)
await expect(overlay).not.toBeVisible()
})
})
})

View File

@@ -1,54 +0,0 @@
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')
})
})

View File

@@ -1,105 +0,0 @@
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)
})
}
)

View File

@@ -158,73 +158,5 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// `subgraph.getActiveGraphId()` is not on core/1.43; inline via app state
const getActiveGraphId = () =>
comfyPage.page.evaluate(() => window.app!.canvas?.graph?.id ?? null)
const subgraphId = await getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
// `searchBoxV2.open()` is not on core/1.43; the canvas double-click
// opens the v2 search box when Comfy.NodeSearchBoxImpl is set to default
const { searchBoxV2 } = comfyPage
await comfyPage.canvas.dblclick({ position: { x: 400, y: 300 } })
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.page.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect.poll(() => getActiveGraphId()).toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -1,42 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -2,9 +2,8 @@ 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 loadWorkflowAndOpenErrorsTab(
export async function openErrorsTabViaSeeErrors(
comfyPage: ComfyPage,
workflow: string
) {
@@ -16,30 +15,3 @@ export async function loadWorkflowAndOpenErrorsTab(
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()
}

View File

@@ -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 { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -47,10 +47,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -60,7 +57,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_media_multiple'
)
@@ -71,10 +68,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
@@ -87,10 +81,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -104,10 +95,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -133,10 +121,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -155,10 +140,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -172,10 +154,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -6,10 +6,7 @@ import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,13 +15,17 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await cleanupFakeModel(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()
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
@@ -34,7 +35,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
@@ -45,7 +46,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_models_with_nodes'
)
@@ -53,7 +54,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).toBeHidden()
await expect(locateButton.first()).not.toBeVisible()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
@@ -65,14 +66,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
await copyButton.first().click()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
@@ -82,7 +83,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
@@ -93,65 +94,12 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await openErrorsTabViaSeeErrors(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()
})
})
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { openErrorsTabViaSeeErrors } 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 loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await openErrorsTabViaSeeErrors(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 loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await openErrorsTabViaSeeErrors(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 loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
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 loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
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 loadWorkflowAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)

View File

@@ -1,599 +0,0 @@
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()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,235 +0,0 @@
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
])
})
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -58,44 +58,16 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
expect(newCount).toBe(initialCount - 1)
})
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -80,16 +80,14 @@ test.describe(
throw new Error('Could not open More Options menu - popover not showing')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeHidden()
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {

View File

@@ -2,7 +2,6 @@ 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 }) => {
@@ -233,7 +232,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Restores missing nodes errors silently when switching back to workflow', async ({
test('Reports missing nodes warning again when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -255,17 +254,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should NOT
// reappear (silent restore), but errors tab should have content
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
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()
await expect(errorOverlay).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -560,28 +560,6 @@ 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)
})
})
}
)

View File

@@ -1,290 +0,0 @@
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
)
})
}
)

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -152,79 +151,4 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -83,24 +83,6 @@ test.describe('Vue Node Context Menu', () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -2,95 +2,8 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { NodeError } from '@/schemas/apiSchema'
const ERROR_CLASS = /ring-destructive-background/
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
const PROMPT_ROUTE_PATTERN = '**/api/prompt'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
node_errors: {
[loadImageId]: buildLoadImageRequiredInputError()
},
error: {
type: 'prompt_outputs_failed_validation',
message: 'Prompt outputs failed validation',
details: ''
}
})
})
},
{ times: 1 }
)
await comfyPage.runButton.click()
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).toBeHidden()
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes
.getNodeLocator(loadImageId)
.getByTestId('node-inner-wrapper'),
imageWidget: await loadImageNode.getWidget(0)
}
}
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -125,72 +38,4 @@ test.describe('Vue Node Error', () => {
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,10 +2,6 @@ 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 }) => {
@@ -25,11 +21,15 @@ test.describe('Advanced Widget Visibility', () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(comfyPage: ComfyPage) {
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(comfyPage: ComfyPage) {
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['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,41 +63,20 @@ 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
}) => {
@@ -118,6 +97,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)).toBeHidden()
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,116 @@
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()
})
}
)

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.18",
"version": "1.44.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

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