Compare commits

...

38 Commits

Author SHA1 Message Date
jaeone94
82997f88c5 Merge branch 'main' into refactor/node-footer-inline-clean 2026-04-07 11:54:45 +09:00
jaeone94
c462c8d061 refactor: address non-blocking review feedback
- Extract footerWrapperBase constant to eliminate third inline duplicate
- Consolidate 3 radius computed into shared getBottomRadius helper
- Narrow useVueElementTracking parameter from MaybeRefOrGetter to string
  (toValue was called eagerly at setup, making reactive updates impossible)
2026-04-07 11:39:46 +09:00
Christian Byrne
84f401bbe9 docs: update backport-management skill with v1.43 session learnings (#10927)
## Summary

Update backport-management skill with learnings from the 2026-04-06
backport session (29 PRs across core/1.42, cloud/1.42, core/1.41,
cloud/1.41).

## Changes

- **What**: Captures operational learnings into the backport skill
reference docs
- Branch scope clarification: app mode and Firebase auth go to both
core+cloud branches, not cloud-only
- Accept-theirs regex warning: produces broken hybrids on component
rewrites (PrimeVue to Reka UI migrations); use `git show SHA:path`
instead
- Missing dependency pattern: cherry-picks can silently bring in code
referencing composables/components not on the target branch
- Fix PRs are expected: plan for 1 fix PR per branch after wave
verification
- `--no-verify` required for worktree commits/pushes (husky hooks fail
in /tmp/ worktrees)
- Automation success varies wildly by branch: core/1.42 got 69%
auto-PRs, cloud/1.42 got 4%
- Test-then-resolve batch pattern for efficient handling of
low-automation branches
- Slack-compatible final deliverables: plain text format replacing
mermaid diagrams (no emojis, tables, headers, or bold)
- Updated conflict triage table with component rewrite, import-only, and
locale/JSON conflict types
- Interactive approval flow replacing static decisions.md for human
review

## Review Focus

Documentation-only change to internal skill files. No production code
affected.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10927-docs-update-backport-management-skill-with-v1-43-session-learnings-33a6d73d3650811aa7cffed4b2d730b0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-06 18:23:21 -07:00
Christian Byrne
0b689a3c3c chore: remove pull-model registry type gen workflow (#9957)
## Summary

Removes `api-update-registry-api-types.yaml` — the workflow that cloned
the private `comfy-api` repo using a PAT to generate registry API types.
This is a security risk: a PAT with private repo access stored on a
public repo.

Type generation now happens in `comfy-api` and pushes PRs to this repo
instead.

### Action needed after merge

- Remove the `COMFY_API_PAT` secret from this repo's settings (Settings
→ Secrets → Actions)

### Depends on

- https://github.com/Comfy-Org/comfy-api/pull/937 (must merge first)
- Refs: COM-16785

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9957-chore-remove-pull-model-registry-type-gen-workflow-3246d73d365081cd9dbad03597817f05)
by [Unito](https://www.unito.io)
2026-04-06 18:21:38 -07:00
Christian Byrne
32ff1a5bdb feat(website): add SEO, sitemap, redirects, CI workflow, and Vercel config (#10156)
## 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-10156-feat-website-add-SEO-sitemap-redirects-CI-workflow-and-Vercel-config-3266d73d3650816ab9eaebd11072d481)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-06 18:20:28 -07:00
Christian Byrne
0a5f281291 refactor: reconcile workspaceAuthStore with authStore (#10485)
## Summary

Add dedicated auth token priority tests and reconcile workspace token
resolution between `authStore` and `workspaceAuthStore`.

## Changes

- **What**: Created `src/stores/__tests__/authTokenPriority.test.ts`
with 6 scenarios covering the full priority chain: workspace token →
Firebase token → API key → null. Added `getWorkspaceToken()` method to
`workspaceAuthStore`. Replaced raw `sessionStorage` reads in `authStore`
with proper store delegation.
- **Files**: 3 files changed (+ 1 new test file)

## Review Focus

- Token priority chain in `authTokenPriority.test.ts` — validates
workspace > Firebase > API key ordering
- The `getAuthToken()` and `getAuthHeader()` methods now delegate to
`workspaceAuthStore` instead of reading sessionStorage directly

## Stack

PR 3/5: #10483#10484 → **→ This PR** → #10486#10487

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-06 18:18:20 -07:00
Christian Byrne
61af482cd4 docs: document when to use page.evaluate vs user actions in browser tests (#10658)
## Summary

Document acceptable vs avoidable `page.evaluate` patterns in Playwright
E2E tests, with migration candidates for existing offenders.

## Changes

- **What**: Add "When to Use `page.evaluate`" section to
`docs/guidance/playwright.md` with acceptable/avoid/preferred guidance
and 8 migration candidates identified from audit

## Review Focus

Whether the migration candidate list covers the right tests and whether
the acceptable/avoid boundary is drawn correctly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10658-docs-document-when-to-use-page-evaluate-vs-user-actions-in-browser-tests-3316d73d36508186be90f263f36daf75)
by [Unito](https://www.unito.io)
2026-04-06 16:56:43 -07:00
Christian Byrne
3a73ce72bb refactor: replace inline getByRole('dialog') with page objects in E2E tests (#10724)
## Summary

Replace remaining inline `getByRole('dialog')` calls in E2E tests with
page object accessors, following the pattern from PR #10586.

**Note:** The original scope covered 9 calls across 5 files. Three of
those refactors (ConfirmDialog, MediaLightbox, TemplatesDialog) were
already merged into main via other PRs, so this PR now covers only the
remaining 3 files.

## Changes

- **ComfyNodeSearchBox.ts**: Extract a `root` locator on
`ComfyNodeSearchFilterSelectionPanel` so `header` getter uses
`this.root` instead of an inline `page.getByRole('dialog')`
- **AppModeHelper.ts**: Add `imagePickerPopover` getter that locates the
PrimeVue Popover (`role="dialog"`) filtered by a button named "All"
- **appModeDropdownClipping.spec.ts**: Replace 4-line inline
`getByRole('dialog')` chain with `comfyPage.appMode.imagePickerPopover`

## Review Focus

Pure test infrastructure refactor — no production code changes. Each
page object follows existing conventions.

Fixes #10723
2026-04-06 16:42:09 -07:00
Benjamin Lu
f3cbbb8654 [codex] merge hashed auth user data into GTM auth events (#10778)
Merge email dataLayer push event into existing signup/login push. Hash
with SHA 256 and deduplicate with a util.

AI summary below

---

## Summary
- attach hashed auth email to the `login` / `sign_up` GTM event payload
instead of pushing a separate standalone `user_data` message
- add a shared telemetry email hashing utility and reuse it for both GTM
(`SHA-256`) and Impact (`SHA-1`)
- extend tests to cover merged auth payloads, both hash algorithms, and
fallback behavior when Web Crypto is unavailable

## Why
The current GTM auth flow was splitting `user_data` and the auth event
into separate dataLayer pushes, which makes GTM preview harder to
interpret and relies on carried-over dataLayer state instead of the
event payload that actually fired.

There was also an implementation gap: the previous GTM change was
intended to hash email client-side, but the provider was still sending
normalized plaintext email into `dataLayer`.

## Impact
Auth events now carry hashed email on the same event payload that GTM
tags consume, so the preview timeline is cleaner and downstream matching
can rely on the auth event directly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10778-codex-merge-hashed-auth-user-data-into-GTM-auth-events-3346d73d36508197b57deada345dbeaa)
by [Unito](https://www.unito.io)
2026-04-06 16:39:49 -07:00
Christian Byrne
e2b07f3e9a feat(website): add Wave 4 secondary pages (#10145)
## 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-10145-feat-website-add-Wave-4-secondary-pages-3266d73d3650818c9101c7d2086c21ba)
by [Unito](https://www.unito.io)
2026-04-06 14:42:24 -07:00
Comfy Org PR Bot
2856a30b50 1.43.13 (#10857)
Patch version increment to 1.43.13

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10857-1-43-13-33a6d73d3650812598bec1f89696cfa5)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-06 13:16:25 -07:00
Dante
64f75f0727 test(assets): add E2E tests for delete confirmation flow (#10785)
## Summary

Add E2E Playwright tests for the asset delete confirmation dialog flow.

## Changes

- **What**: New `Assets sidebar - delete confirmation` describe block in
`assets.spec.ts` covering right-click delete showing confirmation
dialog, confirming delete removes asset with success toast, and
cancelling delete preserves asset. Added `mockDeleteHistory()` to
`AssetsHelper` to intercept POST `/api/history` delete payloads and
update mock state.

## Review Focus

Tests use existing `ConfirmDialog` page object and `AssetsHelper` mock
infrastructure. The `mockDeleteHistory` handler removes jobs from the
in-memory mock array so subsequent `/api/jobs` fetches reflect the
deletion.

Fixes #10781

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10785-test-assets-add-E2E-tests-for-delete-confirmation-flow-3356d73d365081fb90c8e2a69de3a666)
by [Unito](https://www.unito.io)
2026-04-06 10:17:23 -07:00
Dante
0d535631a5 refactor(test): extract dialog page objects from inline getByRole usage (#10822)
## Summary

Extract inline `getByRole('dialog')` calls across E2E tests into
reusable page objects.

## Changes

- **What**: Extract `ConfirmDialog` class from `ComfyPage.ts` into
`browser_tests/fixtures/components/ConfirmDialog.ts` with new `save`
button locator. Add `MediaLightbox` and `TemplatesDialog` page objects.
Refactor 4 test files to use these page objects instead of raw dialog
locators.
- **Skipped**: `appModeDropdownClipping.spec.ts` uses
`getByRole('dialog')` for a PrimeVue Popover (not a true dialog), left
as-is.

## Review Focus

The `ConfirmDialog.click()` method now supports a `save` action used by
`workflowPersistence.spec.ts`, which also waits for the dialog mask to
disappear and workflow service to settle.

Fixes #10723

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10822-refactor-test-extract-dialog-page-objects-from-inline-getByRole-usage-3366d73d365081b3bc0ee7ef0ddce658)
by [Unito](https://www.unito.io)
2026-04-06 09:49:32 -07:00
Comfy Org PR Bot
6c1bf7a3cf 1.43.12 (#10782)
Patch version increment to 1.43.12

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-04 23:48:20 -07:00
Benjamin Lu
b61e15293c test: address review comments on new browser tests (#10852) 2026-04-04 19:26:55 -07:00
Dante
899660b135 test: add queue overlay and workflow search E2E tests (#10802)
## Summary
- Add queue overlay E2E tests: toggle, filter tabs, completed filter,
close (5 tests)
- Add workflow sidebar search E2E tests: search input, filter by name,
clear, no matches (4 tests)
- Fix AssetsHelper mock timestamps from seconds to milliseconds
(matching backend's `int(time.time() * 1000)`)
- Type AssetsHelper response pagination with `JobsListResponse` from
`@comfyorg/ingest-types`

## Test plan
- [ ] CI passes all Playwright shards
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10802-test-add-queue-overlay-and-workflow-search-E2E-tests-3356d73d365081018df8c7061c0854ee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-04-04 13:15:17 -07:00
Benjamin Lu
aeafff1ead fix: virtualize cloud job queue history list (#10592)
## Summary

Virtualize the shared job queue history list so opening the jobs panel
does not eagerly mount the full history on cloud.

## Changes

- **What**: Virtualize the shared queue history list used by the overlay
and sidebar, flatten date headers plus job rows into a single virtual
stream, and preserve hover/menu behavior with updated queue list tests.
- **Why `@tanstack/vue-virtual` instead of Reka virtualizers**: the
installed `reka-ui@2.5.0` does not expose a generic list virtualizer. It
only exposes `ListboxVirtualizer`, `ComboboxVirtualizer`, and
`TreeVirtualizer`, and those components inject `ListboxRoot`/`TreeRoot`
context and carry listbox or tree selection/keyboard semantics. The job
history UI is a flat grouped action list, not a selectable listbox or
navigable tree, so this uses the same TanStack virtualizer layer
directly without forcing the wrong semantics onto the component.

## Review Focus

Please verify the virtual row sizing and inter-group spacing behavior
across date headers and the last row in each group.

> [!TIP]
> Diff reads much cleaner through vscode's unified view with show
leading/trailing whitespace differences enabled

Linear: COM-304

https://tanstack.com/virtual/latest/docs/api/virtualizer

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10592-fix-virtualize-cloud-job-queue-history-list-3306d73d3650819d956bf4b2d8b59a40)
by [Unito](https://www.unito.io)
2026-04-04 12:33:47 -07:00
Johnpaul Chiwetelu
f4fb7a458e test: add browser tests for selection toolbox button actions (#10764) 2026-04-04 14:03:50 +01:00
Yourz
71a3bd92b4 fix: add delete/bookmark actions for blueprints in V2 node library sidebar (#10827)
## Summary

Add missing delete and bookmark actions for user blueprints in the V2
node library sidebar, fixing parity with the V1 sidebar.

## Changes

- **What**: 
- Add delete button (inline + context menu) for user blueprints in
`TreeExplorerV2Node` and `TreeExplorerV2`
- Extract `isUserBlueprint()` helper in `subgraphStore` for DRY usage
across V1/V2 sidebars


![Kapture 2026-04-03 at 00 12
09](https://github.com/user-attachments/assets/3f1f3f41-ed2b-4250-953f-511d39e54e45)

## Review Focus

- `isUserBlueprint` consolidates logic previously duplicated between
`NodeTreeLeaf` and the new V2 components
- Context menu guard `contextMenuNode?.data` prevents showing empty
menus
- Folder `@contextmenu` handler clears stale `contextMenuNode` to
prevent wrong actions

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10827-fix-add-delete-bookmark-actions-for-blueprints-in-V2-node-library-sidebar-3366d73d36508111afd2c2c7d8ff0220)
by [Unito](https://www.unito.io)
2026-04-04 20:14:32 +08:00
Dante
17d2870ef4 test(modelLibrary): add E2E tests for model library sidebar tab (#10789)
## Summary
- Add `ModelLibraryHelper` mock helper for `/experiment/models` and
`/view_metadata` endpoints
- Add `ModelLibrarySidebarTab` page object fixture with search, folder,
and leaf locators
- Add 11 E2E test scenarios covering tab open/close, folder display,
folder expansion, search with debounce, refresh, load all folders, and
empty state

## Test plan
- [ ] CI passes all Playwright shards
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10789-test-modelLibrary-add-E2E-tests-for-model-library-sidebar-tab-3356d73d365081b49a7ed752512164da)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:04:46 +09:00
Dante
7a68943839 test(assets): strengthen pagination E2E assertions (#10773)
## Summary

The existing pagination smoke test only asserts `count >= 1`, which
passes even if the sidebar eagerly loads all items or ignores page
boundaries entirely.

### What changed

**Before:**
- Created 30 mock jobs (less than BATCH_SIZE of 200) — all loaded in one
request, `has_more: false`
- Asserted `count >= 1` — redundant with the grid-render smoke test

**After — two targeted assertions:**

1. **Initial batch < total**: Mock 250 jobs (> BATCH_SIZE 200). First
`/api/jobs?limit=200&offset=0` returns 200 items with `has_more: true`.
Assert `initialCount < 250`.

2. **Scroll triggers second fetch**: Scroll `VirtualGrid` container to
bottom → `approach-end` event → `handleApproachEnd()` →
`assetsStore.loadMoreHistory()` → `/api/jobs?limit=200&offset=200`
fetches remaining 50. Assert `finalCount > initialCount` via
`expect.poll()`.

### Types
Mock data uses `RawJobListItem` from
`src/platform/remote/comfyui/jobs/jobTypes.ts` (Zod-inferred). This is
the correct source-of-truth per `docs/guidance/playwright.md` —
`/api/jobs` is a Python backend endpoint not covered by
`@comfyorg/ingest-types`.

## Test plan
- [ ] CI E2E tests pass
- [ ] `initial batch is smaller than total job count` validates
pagination boundary
- [ ] `scrolling to the end loads additional items` triggers actual
second API call

Fixes #10649

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:00:50 +09:00
Dante
8912f4159a test: add E2E tests for workflow tab operations (#10796)
## Summary

Add Playwright E2E tests for workflow tab interactions in the topbar.

## Changes

- **What**: New test file
`browser_tests/tests/topbar/workflowTabs.spec.ts` with 5 tests covering
default tab visibility, tab creation, switching, closing, and context
menu. Added `newWorkflowButton`, `getTab()`, and `getActiveTab()`
locators to `Topbar.ts` fixture.

## Review Focus

Tests are focused on tab UI interactions only (sidebar workflow
operations are already covered in `workflows.spec.ts`). Context menu
assertion uses Reka UI's `data-reka-context-menu-content` attribute.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10796-test-add-E2E-tests-for-workflow-tab-operations-3356d73d36508170a657ef816e23b71c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:07:31 +09:00
Dante
794b986954 test: add E2E tests for Node Library V2 sidebar (#10798)
## Summary

- Adds Playwright E2E tests for the Node Library V2 sidebar tab
(`Comfy.NodeLibrary.NewDesign: true`)
- Adds `NodeLibrarySidebarTabV2` fixture class with V2-specific locators
(search input, tab buttons, node cards)
- Exposes `menu.nodeLibraryTabV2` on `ComfyPage` for test access
- Tests cover: tab visibility, default tab selection, tab switching,
folder expansion, search filtering, and sort button presence

## Test plan

- [ ] Run `pnpm test:browser:local -- --grep "Node library sidebar V2"`
against a running ComfyUI server with the V2 node library
- [ ] Verify tests pass in CI

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10798-test-add-E2E-tests-for-Node-Library-V2-sidebar-3356d73d36508185a11feaf95e32225b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:04:56 +09:00
Terry Jia
a7b3515692 chore: add @jtydhr88 as code owner for GLSL renderer (#10742)
## Summary
add myself as glsl owner

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10742-chore-add-jtydhr88-as-code-owner-for-GLSL-renderer-3336d73d3650816f84deebf3161aee7a)
by [Unito](https://www.unito.io)
2026-04-02 13:09:58 -07:00
Johnpaul Chiwetelu
26f3f11a3e test: replace raw CSS selectors with TestIds in context menu spec (#10760)
## Summary
- Replace raw CSS selectors (`.lg-node-header`, `.p-contextmenu`,
`.node-title-editor input`, `.image-preview img`) with centralized
`TestIds` constants and existing fixtures in the context menu E2E spec
- Add `data-testid="title-editor-input"` to TitleEditor overlay for
stable selector targeting
- Use `NodeLibrarySidebarTab` fixture for node library sidebar
interaction

## Changes
- `browser_tests/fixtures/selectors.ts`: add `pinIndicator`,
`innerWrapper`, `titleEditorInput`, `mainImage` to `TestIds.node`
- `browser_tests/fixtures/utils/vueNodeFixtures.ts`: add `pinIndicator`
getter
- `src/components/graph/TitleEditor.vue`: add `data-testid` via
`input-attrs`
- `browser_tests/.../contextMenu.spec.ts`: replace all raw selectors
with TestIds/fixtures

## Test plan
- [x] All 23 context menu E2E tests pass locally
- [x] Typecheck passes
- [x] Lint passes

Fixes #10750
Fixes #10749

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10760-test-replace-raw-CSS-selectors-with-TestIds-in-context-menu-spec-3336d73d3650818790c0e32e0b6f1e98)
by [Unito](https://www.unito.io)
2026-04-02 21:04:50 +01:00
jaeone94
d9466947b2 feat: detect and resolve missing media inputs in error tab (#10309)
## Summary

Add detection and resolution UI for missing image/video/audio inputs
(LoadImage, LoadVideo, LoadAudio nodes) in the Errors tab, mirroring the
existing missing model pipeline.

## Changes

- **What**: New `src/platform/missingMedia/` module — scan pipeline
detects missing media files on workflow load (sync for OSS, async for
cloud), surfaces them in the error tab with upload dropzone, thumbnail
library select, and 2-step confirm flow
- **Detection**: `scanAllMediaCandidates()` checks combo widget values
against options; cloud path defers to `verifyCloudMediaCandidates()` via
`assetsStore.updateInputs()`
- **UI**: `MissingMediaCard` groups by media type; `MissingMediaRow`
shows node name (single) or filename+count (multiple), upload dropzone
with drag & drop, `MissingMediaLibrarySelect` with image/video
thumbnails
- **Resolution**: Upload via `/upload/image` API or select from library
→ status card → checkmark confirm → widget value applied, item removed
from error list
- **Integration**: `executionErrorStore` aggregates into
`hasAnyError`/`totalErrorCount`; `useNodeErrorFlagSync` flags nodes on
canvas; `useErrorGroups` renders in error tab
- **Shared**: Extract `ACCEPTED_IMAGE_TYPES`/`ACCEPTED_VIDEO_TYPES` to
`src/utils/mediaUploadUtil.ts`; extract `resolveComboValues` to
`src/utils/litegraphUtil.ts` (shared across missingMedia + missingModel
scan)
- **Reverse clearing**: Widget value changes on nodes auto-remove
corresponding missing media errors (via `clearWidgetRelatedErrors`)

## Testing

### Unit tests (22 tests)
- `missingMediaScan.test.ts` (12): groupCandidatesByName,
groupCandidatesByMediaType (ordering, multi-name),
verifyCloudMediaCandidates (missing/present, abort before/after
updateInputs, already resolved true/false, no-pending skip, updateInputs
spy)
- `missingMediaStore.test.ts` (10): setMissingMedia, clearMissingMedia
(full lifecycle with interaction state), missingMediaNodeIds,
hasMissingMediaOnNode, removeMissingMediaByWidget
(match/no-match/last-entry), createVerificationAbortController

### E2E tests (10 scenarios in `missingMedia.spec.ts`)
- Detection: error overlay shown, Missing Inputs group in errors tab,
correct row count, dropzone + library select visibility, no false
positive for valid media
- Upload flow: file picker → uploading status card → confirm → row
removed
- Library select: dropdown → selected status card → confirm → row
removed
- Cancel: pending selection → returns to upload/library UI
- All resolved: Missing Inputs group disappears
- Locate node: canvas pans to missing media node

## Review Focus

- Cloud verification path: `verifyCloudMediaCandidates` compares widget
value against `asset_hash` — implicit contract
- 2-step confirm mirrors missing model pattern (`pendingSelection` →
confirm/cancel)
- Event propagation guard on dropzone (`@drop.prevent.stop`) to prevent
canvas LoadImage node creation
- `clearAllErrors()` intentionally does NOT clear missing media (same as
missing models — preserves pending repairs)
- `runMissingMediaPipeline` is now `async` and `await`-ed, matching
model pipeline

## Test plan

- [x] OSS: load workflow with LoadImage referencing non-existent file →
error tab shows it
- [x] Upload file via dropzone → status card shows "Uploaded" → confirm
→ widget updated, error removed
- [x] Select from library with thumbnail preview → confirm → widget
updated, error removed
- [x] Cancel pending selection → returns to upload/library UI
- [x] Load workflow with valid images → no false positives
- [x] Click locate-node → canvas navigates to the node
- [x] Multiple nodes referencing different missing files → correct row
count
- [x] Widget value change on node → missing media error auto-removed

## Screenshots


https://github.com/user-attachments/assets/631c0cb0-9706-4db2-8615-f24a4c3fe27d
2026-04-01 17:59:02 +09:00
jaeone94
bb96e3c95c fix: resolve subgraph promoted widget panel regressions (#10648)
## Summary

Fix four bugs in the subgraph promoted widget panel where linked
promotions were not distinguished from independent ones, causing
incorrect UI state in both the SubgraphEditor (Settings) panel and the
Parameters tab WidgetActions menu.

## Changes

- **What**: Add `isLinkedPromotion` helper to correctly identify widgets
driven by subgraph input connections. Fix `disambiguatingSourceNodeId`
lookup mismatch that broke `isWidgetShownOnParents` and
`handleHideInput` for non-nested promoted widgets. Replace fragile CSS
icon selectors with `data-testid` attributes.

## Bugs fixed

Companion fix PR for #10502 (red-green test PR). All 4 E2E tests from
#10502 now pass:

| Bug | Root cause | Fix |
|-----|-----------|-----|
| Linked promoted widgets have hide toggle enabled | `SubgraphEditor`
only checked `node.id === -1` (physical) — linked promotions from
subgraph input connections were not detected | Added `isLinkedPromotion`
helper that checks `input._widget` bindings; `SubgraphNodeWidget`
`:is-physical` prop now covers both physical and linked cases |
| Linked promoted widgets show eye icon instead of link icon | Same root
cause as above — `isPhysical` prop was only true for `node.id === -1` |
Extended the `:is-physical` condition to include `isLinkedPromotion`
check |
| Widget labels show raw names instead of renamed values |
`SubgraphEditor` passed `widget.name` instead of `widget.label \|\|
widget.name` | Changed `:widget-name` binding to prefer `widget.label` |
| WidgetActions menu shows Hide/Show for linked promotions |
`v-if="hasParents"` didn't exclude linked promotions | Added
`canToggleVisibility` computed that combines `hasParents` with
`!isLinked` check via `isLinkedPromotion` |

### Additional bugs discovered and fixed

| Bug | Root cause | Fix |
|-----|-----------|-----|
| "Show input" always displayed instead of "Hide input" for promoted
widgets | `SectionWidgets.isWidgetShownOnParents` used
`getSourceNodeId(widget)` which falls back to `widget.sourceNodeId` when
`disambiguatingSourceNodeId` is undefined — this mismatches the
promotion store key (`undefined`) | Changed to
`widget.disambiguatingSourceNodeId` directly |
| "Hide input" click does nothing | `WidgetActions.handleHideInput` used
`getSourceNodeId(widget)` for the same reason — `demote()` couldn't find
the entry to remove | Same fix — use `widget.disambiguatingSourceNodeId`
directly |

## Tests added

### E2E (Playwright) —
`browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts`

| Test | What it verifies |
|------|-----------------|
| linked promoted widgets have hide toggle disabled | All toggle buttons
in SubgraphEditor shown section are disabled for linked widgets (covers
1-level and 2-level nested promotions via `subgraph-nested-promotion`
fixture) |
| linked promoted widgets show link icon instead of eye icon | Link
icons appear for linked widgets, no eye icons present |
| widget labels display renamed values instead of raw names |
`widget.label` is displayed when set, not `widget.name` |
| linked promoted widget menu should not show Hide/Show input |
Three-dot menu on Parameters tab omits Hide/Show options for linked
promotions, Rename is still available |

### Unit (Vitest) — `src/core/graph/subgraph/promotionUtils.test.ts`

7 tests covering `isLinkedPromotion`: basic matching, negative cases,
nested subgraph with `disambiguatingSourceNodeId`, multiple inputs, and
mixed linked/independent state.

### Unit (Vitest) —
`src/components/rightSidePanel/parameters/WidgetActions.test.ts`

- Added `isSubgraphNode: () => false` to mock nodes to prevent crash
from new `isLinked` computed

## Review Focus

- `isLinkedPromotion` reads `input._widget` (WeakRef-backed,
non-reactive) directly in the template. This is intentional — `_widget`
bindings are set during subgraph initialization before the user opens
the panel, so stale reads don't occur in practice. A computed-based
approach was attempted but reverted because `_widget` changes cannot
trigger Vue reactivity.
- `getSourceNodeId` removal in `SectionWidgets` and `WidgetActions` is
intentional — the old fallback (`?? widget.sourceNodeId`) caused key
mismatches with the promotion store for non-nested widgets.

## Screenshots
Before
<img width="723" height="935" alt="image"
src="https://github.com/user-attachments/assets/09862578-a0d1-45b4-929c-f22f7494ebe2"
/>

After
<img width="999" height="952" alt="image"
src="https://github.com/user-attachments/assets/ed8fe604-6b44-46b9-a315-6da31d6b405a"
/>
2026-04-01 17:10:30 +09:00
jaeone94
77fd8c67fc fix: restore dragged-node1 screenshot (CI timing fluke) 2026-03-31 21:27:47 +09:00
jaeone94
47c190a08d fix: address code review for layoutStore.collapsedSize
- Clear collapsedSizes in initializeFromLiteGraph to prevent stale
  entries from previous workflows
- Merge collapsedSize in customRef getter instead of setter to ensure
  persistence across reads
- Use trigger() instead of nodeRef.value assignment in
  updateNodeCollapsedSize
2026-03-31 21:14:17 +09:00
github-actions
9fa048fa6a [automated] Update test expectations 2026-03-31 12:12:10 +00:00
jaeone94
d434f770dc test: add legacy mode tests and refactor shared assertion
- Add 4 legacy mode selection bounding box tests (expanded/collapsed
  x bottom-left/bottom-right)
- Extract assertSelectionEncompassesNodes shared helper
- Add boundingRect fallback for legacy nodes without DOM elements
- Rename Vue mode test describe for symmetry
2026-03-31 20:53:26 +09:00
jaeone94
87f394ac77 refactor: replace onBounding with layoutStore.collapsedSize
- Move min-height to root element (removes footer height accumulation)
- Remove onBounding callback and vueBoundsOverrides Map entirely
- Add collapsedSize field to layoutStore for collapsed node dimensions
- selectionBorder reads collapsedSize for collapsed nodes in Vue mode
- No litegraph core changes, no onBounding usage
2026-03-31 20:28:10 +09:00
Dante
8c7e629021 Merge branch 'main' into refactor/node-footer-inline-clean 2026-03-31 09:05:43 +09:00
jaeone94
117b4e152f fix: restore added-node screenshot (CI timing fluke) 2026-03-30 21:47:33 +09:00
github-actions
ef1a64141a [automated] Update test expectations 2026-03-30 12:42:59 +00:00
jaeone94
ebe23e682c fix: invalidate cached measurement on collapse and clarify padding source 2026-03-30 21:25:07 +09:00
jaeone94
3f1839b6b1 refactor: address code review feedback
- Use reactive props destructuring in NodeFooter
- Remove dead isBackground parameter, replace getTabStyles with
  tabStyles constant
- Extract errorWrapperStyles to eliminate 3x class duplication
- Skip vueBoundsOverrides entry when footerHeight is 0
2026-03-30 20:57:48 +09:00
jaeone94
e676c33c78 refactor: inline node footer with isolate -z-1 and onBounding overrides
- Replace absolute overlay footer with inline flow layout
- Use isolate -z-1 on footer wrapper to keep it behind body without
  adding z-index to body (preserving slot stacking freedom)
- Remove footer offset computed classes (footerStateOutlineBottomClass,
  footerRootBorderBottomClass, footerResizeHandleBottomClass, hasFooter)
- Add vueBoundsOverrides Map for DOM-measured footer/collapsed dimensions
- Use onBounding callback to extend boundingRect from vueBoundsOverrides
- Measure body (node-inner-wrapper) for node.size to prevent footer
  height accumulation on Vue/legacy mode switching
- Safe onBounding cleanup (only restore if not wrapped by another)
- Clean up vueBoundsOverrides entries on node unmount
- Add shared test helpers and 8 parameterized E2E tests
2026-03-30 20:41:47 +09:00
154 changed files with 10977 additions and 1053 deletions

View File

@@ -11,10 +11,11 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
4. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
5. **Verify**After each wave, verify branch integrity before proceeding
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
4. **Plan**Order by dependency (leaf fixes first), group into waves per branch
5. **Execute**Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
6. **Verify** — After each wave, verify branch integrity before proceeding
7. **Log & Report** — Generate session report (`reference/logging.md`)
## System Context
@@ -37,16 +38,29 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
**Critical: Match PRs to the correct target branches.**
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ----------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
| Branch prefix | Scope | Example |
| ------------- | ------------------------------ | ------------------------------------------------- |
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
### What Goes Where
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
**Both core + cloud:**
- **App mode** PRs — app mode is NOT cloud-only
- **Firebase auth** PRs — Firebase auth is on core for API nodes
- **Payment redirect** PRs — payment infrastructure shared
- **Bug fixes** touching shared components
**Cloud-only (skip for core):**
- Team workspaces
- Cloud queue virtualization
- Hide API key login
- Cloud-specific UI behind cloud feature flags
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
## ⚠️ Gotchas (Learn from Past Sessions)
@@ -67,6 +81,32 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
### Accept-Theirs Can Produce Broken Hybrids
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
### Cherry-Picks Can Reference Missing Dependencies
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
### Use `--no-verify` for Worktree Pushes
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
### Automation Success Varies Wildly by Branch
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
## Conflict Triage
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
@@ -77,6 +117,8 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
@@ -103,7 +145,7 @@ Skip these without discussion:
- **Test-only / lint rule changes** — Not user-facing
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
## Wave Verification
@@ -122,6 +164,18 @@ git worktree remove /tmp/verify-TARGET --force
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
### Fix PRs Are Normal
Expect to create 1 fix PR per branch after verification. Common issues:
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
### Never Admin-Merge Without CI
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
@@ -135,6 +189,43 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo
- Reserve session-style bulk backporting for catching up after gaps
- When a release branch is created, immediately start the continuous process
## Interactive Approval Flow
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
### Batch Presentation
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
```
# | PR | Title | Target | Rec | Context
----+--------+------------------------------------------+---------------+------+--------
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
```
Each row includes:
- PR number and title
- Target branches
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
### Human Response Format
- `Y` — approve for backport
- `N` — skip
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
- Any freeform question or comment triggers discussion before moving on
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
### Rules
- ALL candidates are reviewed, not just MUST items
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
## Quick Reference
### Label-Driven Automation (default path)
@@ -150,13 +241,96 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
```bash
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
cd /tmp/backport-$BRANCH
# For each PR:
git fetch origin $BRANCH
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
git cherry-pick -m 1 $MERGE_SHA
# Resolve conflicts, push, create PR, merge
# Resolve conflicts (see Conflict Triage)
git push origin backport-$PR-to-$BRANCH --no-verify
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
--title "[backport $BRANCH] $TITLE (#$PR)" \
--body "Backport of #$PR. [conflict notes]"
gh pr merge $NEW_PR --squash --admin
sleep 25
```
### Efficient Batch: Test-Then-Resolve Pattern
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
```bash
cd /tmp/backport-$BRANCH
for pr in "${ORDER[@]}"; do
git checkout -b test-$pr origin/$BRANCH
if git cherry-pick -m 1 $SHA 2>/dev/null; then
echo "CLEAN: $pr"
else
echo "CONFLICT: $pr"
git cherry-pick --abort
fi
git checkout --detach HEAD
git branch -D test-$pr
done
```
Then process clean PRs in a batch loop, conflicts individually.
### PR Title Convention
```
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
```
## Final Deliverables (Slack-Compatible)
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
### 1. Author Accountability Report
File: `backport-author-accountability.md`
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
```
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
- author-login
- #1234 fix: short title
- #5678 fix: another title
- other-author
- #9012 fix: some other fix
```
Authors sorted alphabetically, 4-space indent for nested items.
### 2. Slack Status Update
File: `slack-status-update.md`
A shareable summary of the session. Structure:
```
Backport session complete -- YYYY-MM-DD
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
Branches updated:
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
- ...
N total PRs created and merged (N backports + N fix PRs).
Notable fixes included:
- [category]: [list of fixes]
- ...
Conflict patterns encountered:
- [pattern and how it was resolved]
- ...
N authors had PRs backported. See author accountability list for details.
```
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.

View File

@@ -23,10 +23,10 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi
**Before categorizing, filter by branch scope:**
| Target branch | Skip if PR is... |
| ------------- | ------------------------------------------------------------------- |
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
| `cloud/*` | Local-only features not present on cloud branch |
| Target branch | Skip if PR is... |
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
| `cloud/*` | Local-only features not present on cloud branch |
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
@@ -61,8 +61,6 @@ done
## Human Review Checkpoint
Present decisions.md before execution. Include:
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
1. All MUST/SHOULD/SKIP categorizations with rationale
2. Questions for human (feature existence, scope, deps)
3. Estimated effort per branch
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.

View File

@@ -73,14 +73,22 @@ for PR in ${CONFLICT_PRS[@]}; do
git cherry-pick -m 1 $MERGE_SHA
# If conflict — NEVER skip based on file count alone!
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
# See SKILL.md Conflict Triage table for resolution per type.
# For component rewrites (4+ markers in a .vue file, library migration):
# DO NOT use accept-theirs regex — it produces broken hybrids.
# Instead, use the complete file from the merge commit:
# git show $MERGE_SHA:path/to/file > path/to/file
# For simple content conflicts, accept theirs:
# python3 -c "import re; ..."
# Resolve all conflicts, then:
git add .
GIT_EDITOR=true git cherry-pick --continue
git push origin backport-$PR-to-TARGET
git push origin backport-$PR-to-TARGET --no-verify
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
--title "[backport TARGET] TITLE (#$PR)" \
--body "Backport of #$PR..." | grep -oP '\d+$')
@@ -114,7 +122,30 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes
git worktree remove /tmp/verify-TARGET --force
```
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
If verification fails, **do not skip** — create a fix PR:
```bash
# Stay in the verify worktree
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
# Common fixes:
# 1. Component rewrite hybrids: overwrite with merge commit version
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
# 2. Missing dependency files
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
# 3. Missing type properties: edit the interface
# 4. Unused imports: delete the import lines
git add -A
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
git push origin fix-backport-TARGET --no-verify
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
gh pr merge $PR --squash --admin
```
Do not proceed to the next branch until typecheck passes.
## Conflict Resolution Patterns
@@ -142,7 +173,35 @@ git rm $FILE
git checkout --theirs $FILE && git add $FILE
```
### 4. Locale Files
### 4. Component Rewrites (DO NOT accept-theirs)
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
a broken hybrid with mismatched template/script sections.
```bash
# Use the complete correct file from the merge commit instead:
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
```
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
### 5. Missing Dependencies After Cherry-Pick
Cherry-picks can succeed but leave the branch broken because the PR's code on main
references composables/components introduced by an earlier PR.
```bash
# Add the missing file from the merge commit:
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
```
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
### 6. Locale Files
Usually adding new i18n keys — accept theirs, validate JSON:
@@ -176,8 +235,14 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
8. **Always validate JSON** after resolving locale file conflicts
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
12. **Never admin-merge without CI**`--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
## CI Failure Triage

View File

@@ -2,26 +2,25 @@
## During Execution
Maintain `execution-log.md` with per-branch tables:
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
```markdown
| PR# | Title | CI Status | Status | Backport PR | Notes |
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
| PR# | Title | Status | Backport PR | Notes |
| ----- | ----- | ------ | ----------- | ------- |
| #XXXX | Title | merged | #YYYY | Details |
```
## Wave Verification Log
Track verification results per wave:
Track verification results per wave within execution-log.md:
```markdown
## Wave N Verification TARGET_BRANCH
Wave N Verification -- TARGET_BRANCH
- PRs merged: #A, #B, #C
- Typecheck: ✅ Pass / ❌ Fail
- Unit tests: ✅ Pass / ❌ Fail
- Typecheck: pass / fail
- Fix PR: #YYYY (if needed)
- Issues found: (if any)
- Human review needed: (list any non-trivial conflict resolutions)
```
## Session Report Template
@@ -63,40 +62,42 @@ Track verification results per wave:
- Feature branches that need tracking for future sessions?
```
## Final Deliverable: Visual Summary
## Final Deliverables
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
```mermaid
graph TD
subgraph branch1["☁️ cloud/X.XX — N PRs"]
C1["#XXXX title"]
C2["#XXXX title"]
end
### 1. execution-log.md (internal)
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
M1["#XXXX title"]
end
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
S1["#XXXX-#XXXX N auto-merged"]
S2["#XXXX-#XXXX N manual picks"]
end
### 2. backport-author-accountability.md (Slack-compatible)
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
```
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
### 3. slack-status-update.md (Slack-compatible)
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
## Slack Formatting Rules
Both shareable files (author accountability + status update) must follow these rules:
- No emojis (no checkmarks, no arrows, no icons)
- No markdown tables (use plain lists with dashes)
- No headers (no # or ##)
- No bold (\*_) or italic (_)
- No inline code backticks
- Use -- instead of em dash
- Use plain dashes (-) for lists with 4-space indent for nesting
- Line breaks between sections for readability
These files should paste directly into a Slack message and look clean.
## Files to Track
- `candidate_list.md` — all candidates per branch
- `decisions.md` — MUST/SHOULD/SKIP with rationale
- `wave-plan.md` — execution order
- `execution-log.md` — real-time status
- `backport-session-report.md` — final summary
All in `~/temp/backport-session/`:
All in `~/temp/backport-session/`.
- `execution-plan.md` -- approved PRs with merge SHAs (input)
- `execution-log.md` -- real-time status with per-branch tables (internal)
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
- `slack-status-update.md` -- session summary (Slack-compatible)

View File

@@ -1,107 +0,0 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
on:
# Manual trigger
workflow_dispatch:
# Triggered from comfy-api repo
repository_dispatch:
types: [comfy-api-updated]
jobs:
update-registry-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
token: ${{ secrets.COMFY_API_PAT }}
clean: true
- name: Get API commit information
id: api-info
run: |
cd comfy-api
API_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate API types
run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in Comfy Registry API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
- API commit: ${{ steps.api-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
base: main
labels: CNR
delete-branch: true
add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts

33
.github/workflows/ci-website-build.yaml vendored Normal file
View File

@@ -0,0 +1,33 @@
# Description: Build and validate the marketing website (apps/website)
name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build website
run: pnpm --filter @comfyorg/website build

View File

@@ -69,6 +69,9 @@
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
integrations: [vue(), sitemap()],
vite: {
plugins: [tailwindcss()]
},

View File

@@ -9,6 +9,7 @@
"preview": "astro preview"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://comfy.org/sitemap-index.xml

View File

@@ -96,6 +96,10 @@ const socials = [
v-for="link in column.links"
:key="link.href"
:href="link.href"
:target="link.href.startsWith('http') ? '_blank' : undefined"
:rel="
link.href.startsWith('http') ? 'noopener noreferrer' : undefined
"
class="text-sm text-smoke-700 transition-colors hover:text-white"
>
{{ link.label }}

View File

@@ -50,6 +50,8 @@ const filteredTestimonials = computed(() => {
<button
v-for="industry in industries"
:key="industry"
type="button"
:aria-pressed="activeFilter === industry"
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
:class="
activeFilter === industry

View File

@@ -37,6 +37,8 @@ const activeCategory = ref(0)
<button
v-for="(category, index) in categories"
:key="category"
type="button"
:aria-pressed="index === activeCategory"
class="transition-colors"
:class="
index === activeCategory

View File

@@ -7,12 +7,14 @@ interface Props {
title: string
description?: string
ogImage?: string
noindex?: boolean
}
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
noindex = false,
} = Astro.props
const siteBase = Astro.site ?? 'https://comfy.org'
@@ -21,6 +23,29 @@ const ogImageURL = new URL(ogImage, siteBase)
const locale = Astro.currentLocale ?? 'en'
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/favicon.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
---
<!doctype html>
@@ -29,26 +54,36 @@ const gtmEnabled = import.meta.env.PROD
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImageURL.href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={canonicalURL.href} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content="Comfy" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@comaboratory" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
<script type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
<!-- Google Tag Manager -->
{gtmEnabled && (
<script is:inline define:vars={{ gtmId }}>

View File

@@ -0,0 +1,176 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
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' },
]
const collaborators = [
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
]
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.' },
]
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: 'Who is behind ComfyUI?',
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
},
{
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: 'What are the future plans?',
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
},
]
---
<BaseLayout title="About — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
Crafting the next frontier of visual and audio media
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
An open-source community and company building the most powerful tools for generative AI creators.
</p>
</section>
<!-- Our Mission -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
We want to build the operating system for Gen 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.
</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>
<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">
<h3 class="text-lg font-semibold">{project.name}</h3>
<p class="mt-2 text-sm text-smoke-700">{project.description}</p>
</div>
))}
</div>
</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>
<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.
</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>
<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">
<div class="mx-auto h-16 w-16 rounded-full bg-charcoal-600" />
<h3 class="mt-4 font-semibold">{member.name}</h3>
<p class="mt-1 text-sm text-smoke-700">{member.role}</p>
</div>
))}
</div>
</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>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
<div class="mx-auto h-14 w-14 rounded-full bg-charcoal-600" />
<p class="mt-3 font-semibold">{person.name}</p>
</div>
))}
</div>
</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>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
<h3 class="text-lg font-semibold">{faq.q}</h3>
<p class="mt-2 text-smoke-700">{faq.a}</p>
</div>
))}
</div>
</div>
</section>
<!-- Join Our Team 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>
<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.
</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 />
</BaseLayout>

View File

@@ -0,0 +1,203 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import SiteFooter from '../components/SiteFooter.vue'
const departments = [
{
name: 'Engineering',
roles: [
{ title: 'Design Engineer', id: 'abc787b9-ad85-421c-8218-debd23bea096' },
{ title: 'Software Engineer, ComfyUI Desktop', id: 'ad2f76cb-a787-47d8-81c5-7e7f917747c0' },
{ title: 'Product Manager, ComfyUI (open-source)', id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c' },
{ title: 'Senior Software Engineer, Frontend Generalist', id: 'c3e0584d-5490-491f-aae4-b5922ef63fd2' },
{ title: 'Software Engineer, Frontend Generalist', id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40' },
{ title: 'Tech Lead Manager, Frontend', id: 'a0665088-3314-457a-aa7b-12ca5c3eb261' },
{ title: 'Senior Software Engineer, Comfy Cloud', id: '27cdf4ce-69a4-44da-b0ec-d54875fd14a1' },
{ title: 'Senior Applied AI/ML Engineer', id: '5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0' },
{ title: 'Senior Engineer, Backend Generalist', id: '732f8b39-076d-4847-afe3-f54d4451607e' },
{ title: 'Software Engineer, Core ComfyUI Contributor', id: '7d4062d6-d500-445a-9a5f-014971af259f' },
],
},
{
name: 'Design',
roles: [
{ title: 'Graphic Designer', id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f' },
{ title: 'Creative Artist', id: '19ba10aa-4961-45e8-8473-66a8a7a8079d' },
{ title: 'Senior Product Designer', id: 'b2e864c6-4754-4e04-8f46-1022baa103c3' },
{ title: 'Freelance Motion Designer', id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b' },
],
},
{
name: 'Marketing',
roles: [
{ title: 'Partnership & Events Marketing Manager', id: '89d3ff75-2055-4e92-9c69-81feff55627c' },
{ title: 'Lifecycle Growth Marketer', id: 'be74d210-3b50-408c-9f61-8fee8833ce64' },
{ title: 'Social Media Manager', id: '28dea965-662b-4786-b024-c9a1b6bc1f23' },
],
},
{
name: 'BizOps/Growth',
roles: [
{ title: 'Founding Account Executive', id: '061ff83a-fe18-40f7-a5c4-4ce7da7086a6' },
{ title: 'Senior Technical Recruiter', id: 'd5008532-c45d-46e6-ba2c-20489d364362' },
],
},
]
const whyJoinUs = [
'You want to build tools that empower others to create.',
"You like working on foundational tech that's already powering real-world videos, images, music, and apps.",
'You care about open, free alternatives to closed AI platforms.',
'You believe artists, hackers, and solo builders should have real control over their tools.',
'You want to work in a small, sharp, no-BS team that moves fast and ships often.',
]
const notAFit = [
"You need everything planned out. We're figuring things out as we go and changing direction fast. If uncertainty stresses you out, you probably won't have fun here.",
'You see yourself as just a coder/[insert job title]. Around here, everyone does a bit of everything — talking to users, writing docs, whatever needs to get done. We need people who jump in wherever they can help.',
"You need well-defined processes. We're building something revolutionary, which means making up the playbook. If you need clear structures, this might drive you nuts.",
"You like having a manager checking in on you. We trust people to own their work end-to-end. When you see something broken, you fix it — no permission needed.",
"You prefer waiting until you have all the facts. We often have to make calls with incomplete info and adjust as we learn more. Analysis paralysis doesn't work here.",
"You bring a huge ego to the table. We're all about pushing boundaries and solving hard problems, not individual heroics. If you can't take direct feedback or learn from mistakes, this isn't your spot.",
]
const questions = [
'What is the team culture?',
'What kind of background do I need to have to apply?',
'How do I apply?',
'What does the hiring process look like?',
'In-person vs remote?',
'How can I increase my chances of getting the job?',
'What if I need visa sponsorship to work in the US?',
'Can I get feedback for my resume and interview?',
]
---
<BaseLayout
title="Careers — Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<SiteNav client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-4xl font-bold tracking-tight md:text-6xl">
Building an &ldquo;operating system&rdquo;
<br />
<span class="text-brand-yellow">for Gen AI</span>
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
We're the world's leading <strong class="text-white">visual AI platform</strong> — an open, modular system
where anyone can build, customize, and automate AI workflows with precision and full control. Unlike most AI
tools that hide their inner workings behind a simple prompt box, we give professionals the
<strong class="text-white">freedom to design their own pipelines</strong> — connecting models, tools, and
logic visually like building blocks.
</p>
<p class="mx-auto mt-4 max-w-2xl text-lg leading-relaxed text-smoke-700">
ComfyUI is used by <strong class="text-white">artists, filmmakers, video game creators, designers, researchers, VFX houses</strong>,
and among others, <strong class="text-white">teams at OpenAI, Netflix, Amazon Studios, Ubisoft, EA, and Tencent</strong>
— all who want to go beyond presets and truly shape how AI creates.
</p>
</div>
</section>
<!-- Job Listings -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl">
{departments.map((dept) => (
<div class="mb-16 last:mb-0">
<h2 class="mb-6 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
{dept.name}
</h2>
<div class="space-y-3">
{dept.roles.map((role) => (
<a
href={`https://jobs.ashbyhq.com/comfy-org/${role.id}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-between rounded-lg border border-white/10 px-5 py-4 transition-colors hover:border-brand-yellow"
>
<span class="font-medium">{role.title}</span>
<span class="text-sm text-smoke-700">→</span>
</a>
))}
</div>
</div>
))}
</div>
</section>
<!-- Why Join Us / When It's Not a Fit — 2-column layout -->
<section class="border-t border-white/10 bg-charcoal-800 px-6 py-24">
<div class="mx-auto grid max-w-5xl gap-16 md:grid-cols-2">
<!-- Why Join Us -->
<div>
<h2 class="mb-8 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
Why Join Us?
</h2>
<ul class="space-y-4">
{whyJoinUs.map((item) => (
<li class="flex gap-3 text-smoke-700">
<span class="mt-1 text-brand-yellow">•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
<!-- When It's Not a Fit -->
<div>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-widest text-white">
When It&rsquo;s Not a Fit
</h2>
<p class="mb-8 text-sm text-smoke-700">
Working at Comfy Org isn&rsquo;t for everyone, and that&rsquo;s totally fine. You might want to look
elsewhere if:
</p>
<ul class="space-y-4">
{notAFit.map((item) => (
<li class="flex gap-3 text-sm text-smoke-700">
<span class="mt-0.5 text-white/40">—</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
<!-- Q&A -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl">
<h2 class="mb-10 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
Q&amp;A
</h2>
<ul class="space-y-4">
{questions.map((q) => (
<li class="rounded-lg border border-white/10 px-5 py-4 font-medium">
{q}
</li>
))}
</ul>
</div>
</section>
<!-- Contact CTA -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-3xl font-bold">Questions? Reach out!</h2>
<a
href="https://support.comfy.org/"
target="_blank"
rel="noopener noreferrer"
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"
>
Contact us
</a>
</div>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,80 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import SiteFooter from '../components/SiteFooter.vue'
const cards = [
{
icon: '🪟',
title: 'Windows',
description: 'Requires NVIDIA or AMD graphics card',
cta: 'Download for Windows',
href: 'https://download.comfy.org/windows/nsis/x64',
outlined: false,
},
{
icon: '🍎',
title: 'Mac',
description: 'Requires Apple Silicon (M-series)',
cta: 'Download for Mac',
href: 'https://download.comfy.org/mac/dmg/arm64',
outlined: false,
},
{
icon: '🐙',
title: 'GitHub',
description: 'Build from source on any platform',
cta: 'Install from GitHub',
href: 'https://github.com/comfyanonymous/ComfyUI',
outlined: true,
},
]
---
<BaseLayout title="Download — Comfy">
<SiteNav client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
Download ComfyUI
</h1>
<p class="mt-4 text-lg text-smoke-700">
Experience AI creation locally
</p>
<div class="mt-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{cards.map((card) => (
<a
href={card.href}
class="flex flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
>
<span class="text-4xl" aria-hidden="true">{card.icon}</span>
<h2 class="mt-4 text-xl font-semibold text-white">{card.title}</h2>
<p class="mt-2 text-sm text-smoke-700">{card.description}</p>
<span
class:list={[
'mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90',
card.outlined
? 'border border-brand-yellow text-brand-yellow'
: 'bg-brand-yellow text-black',
]}
>
{card.cta}
</span>
</a>
))}
</div>
<div class="mt-20 rounded-xl border border-white/10 bg-charcoal-800 p-8">
<p class="text-lg text-smoke-700">
No GPU?{' '}
<a
href="https://app.comfy.org"
class="font-semibold text-brand-yellow hover:underline"
>
Try Comfy Cloud →
</a>
</p>
</div>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import SiteFooter from '../components/SiteFooter.vue'
---
<BaseLayout title="Gallery — Comfy">
<SiteNav client:load />
<main class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
Built, Tweaked, and <span class="text-brand-yellow">Dreamed</span> in ComfyUI
</h1>
<p class="mx-auto mt-4 max-w-2xl text-lg text-smoke-700">
A small glimpse of what's being created with ComfyUI by the community.
</p>
</section>
<!-- Placeholder Grid -->
<section class="mx-auto max-w-6xl px-6 pb-24">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map(() => (
<div class="flex aspect-video items-center justify-center rounded-xl border border-white/10 bg-charcoal-600">
<p class="text-sm text-smoke-700">Community showcase coming soon</p>
</div>
))}
</div>
</section>
<!-- CTA -->
<section class="mx-auto max-w-3xl px-6 pb-32 text-center">
<h2 class="text-2xl font-semibold">Have something cool to share?</h2>
<a
href="https://support.comfy.org/"
class="mt-6 inline-block rounded-full bg-brand-yellow px-8 py-3 font-medium text-black transition hover:opacity-90"
>
Get in Touch
</a>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,273 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import SiteFooter from '../components/SiteFooter.vue'
---
<BaseLayout
title="Privacy Policy — Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24">
<h1 class="text-3xl font-bold text-white">Privacy Policy</h1>
<p class="mt-2 text-sm text-smoke-500">Effective date: April 18, 2025</p>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">Introduction</h2>
<p class="text-sm leading-relaxed text-smoke-500">
Your privacy is important to us. This Privacy Policy explains how Comfy
Org, Inc. ("Comfy," "we," "us," or "our") collects, uses, shares, and
protects your personal information when you use our website at comfy.org
and related services.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Information We Collect
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We may collect the following personal information when you interact with
our services:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>Name</li>
<li>Email address</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
We collect this information when you voluntarily provide it to us, such
as when you create an account, subscribe to communications, or contact
support.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Legitimate Reasons for Processing Your Personal Information
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We only collect and use your personal information when we have a
legitimate reason for doing so. We process personal information to
provide, improve, and administer our services; to communicate with you;
for security and fraud prevention; and to comply with applicable law.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
How Long We Keep Your Information
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We retain your personal information only for as long as necessary to
fulfill the purposes outlined in this policy, unless a longer retention
period is required or permitted by law. When we no longer need your
information, we will securely delete or anonymize it.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Children's Privacy
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We do not knowingly collect personal information from children under the
age of 13. If we learn that we have collected personal information from a
child under 13, we will take steps to delete such information as quickly
as possible. If you believe we have collected information from a child
under 13, please contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Disclosure of Personal Information to Third Parties
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We may disclose personal information to third-party service providers
that assist us in operating our services. This includes payment
processors such as Stripe, cloud hosting providers, and analytics
services. We require these parties to handle your data in accordance with
this policy and applicable law.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Your Rights and Controlling Your Personal Information
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
Depending on your location, you may have the following rights regarding
your personal information:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>The right to access the personal information we hold about you.</li>
<li>
The right to request correction of inaccurate personal information.
</li>
<li>The right to request deletion of your personal information.</li>
<li>The right to object to or restrict processing.</li>
<li>The right to data portability.</li>
<li>The right to withdraw consent at any time.</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
To exercise any of these rights, please contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Limits of Our Policy
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
Our website may link to external sites that are not operated by us.
Please be aware that we have no control over the content and practices of
these sites and cannot accept responsibility for their respective privacy
policies.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Changes to This Policy
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We may update this Privacy Policy from time to time to reflect changes in
our practices or for other operational, legal, or regulatory reasons. We
will notify you of any material changes by posting the updated policy on
our website with a revised effective date.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
U.S. State Privacy Compliance
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
We comply with privacy laws in the following U.S. states, where
applicable:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>California (CCPA / CPRA)</li>
<li>Colorado (CPA)</li>
<li>Delaware (DPDPA)</li>
<li>Florida (FDBR)</li>
<li>Virginia (VCDPA)</li>
<li>Utah (UCPA)</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
Residents of these states may have additional rights, including the right
to know what personal information is collected, the right to delete
personal information, and the right to opt out of the sale of personal
information. To exercise these rights, contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">Do Not Track</h2>
<p class="text-sm leading-relaxed text-smoke-500">
Some browsers include a "Do Not Track" (DNT) feature that signals to
websites that you do not wish to be tracked. There is currently no
uniform standard for how companies should respond to DNT signals. At this
time, we do not respond to DNT signals.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">CCPA / CPPA</h2>
<p class="text-sm leading-relaxed text-smoke-500">
Under the California Consumer Privacy Act (CCPA) and the California
Privacy Protection Agency (CPPA) regulations, California residents have
the right to know what personal information we collect, request deletion
of their data, and opt out of the sale of their personal information. We
do not sell personal information. To make a request, contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
GDPR — European Economic Area
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
If you are located in the European Economic Area (EEA), the General Data
Protection Regulation (GDPR) grants you certain rights regarding your
personal data, including the right to access, rectify, erase, restrict
processing, data portability, and to object to processing. Our legal
bases for processing include consent, contract performance, and
legitimate interests. To exercise your GDPR rights, contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">UK GDPR</h2>
<p class="text-sm leading-relaxed text-smoke-500">
If you are located in the United Kingdom, the UK General Data Protection
Regulation (UK GDPR) provides you with similar rights to those under the
EU GDPR, including the right to access, rectify, erase, and port your
data. To exercise your rights under the UK GDPR, please contact us at
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org</a
>.
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
Australian Privacy
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
If you are located in Australia, the Australian Privacy Principles (APPs)
under the Privacy Act 1988 apply to our handling of your personal
information. You have the right to request access to and correction of
your personal information. If you believe we have breached the APPs, you
may lodge a complaint with us or with the Office of the Australian
Information Commissioner (OAIC).
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">Contact Us</h2>
<p class="text-sm leading-relaxed text-smoke-500">
If you have any questions or concerns about this Privacy Policy or our
data practices, please contact us at:
</p>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
</p>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,304 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import SiteNav from '../components/SiteNav.vue'
import SiteFooter from '../components/SiteFooter.vue'
---
<BaseLayout
title="Terms of Service — Comfy"
description="Terms of Service for ComfyUI and related Comfy services."
noindex
>
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
<header class="mb-16">
<h1 class="text-3xl font-bold text-white">Terms of Service</h1>
<p class="mt-2 text-lg text-smoke-700">for ComfyUI</p>
<p class="mt-4 text-sm text-smoke-700">
Effective Date: March 1, 2025 · Last Updated: March 1, 2025
</p>
</header>
<section class="space-y-12">
<!-- Intro -->
<div>
<p class="text-sm leading-relaxed text-smoke-700">
Welcome to ComfyUI and the services provided by Comfy Org, Inc.
("Comfy", "we", "us", or "our"). By accessing or using ComfyUI, the
Comfy Registry, comfy.org, or any of our related services
(collectively, the "Services"), you agree to be bound by these Terms of
Service ("Terms"). If you do not agree to these Terms, do not use the
Services.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 1. Definitions -->
<div>
<h2 class="text-xl font-semibold text-white">1. Definitions</h2>
<ul class="mt-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-smoke-700">
<li>
<strong class="text-white">"ComfyUI"</strong> means the open-source
node-based visual programming interface for AI-powered content
generation.
</li>
<li>
<strong class="text-white">"Services"</strong> means ComfyUI, the
Comfy Registry, comfy.org website, APIs, documentation, and any
related services operated by Comfy.
</li>
<li>
<strong class="text-white">"User Content"</strong> means any
content, workflows, custom nodes, models, or other materials you
create, upload, or share through the Services.
</li>
<li>
<strong class="text-white">"Registry"</strong> means the Comfy
Registry, a package repository for distributing custom nodes and
extensions.
</li>
</ul>
<!-- Full legal text to be added -->
</div>
<!-- 2. ComfyUI Software License -->
<div>
<h2 class="text-xl font-semibold text-white">
2. ComfyUI Software License
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
ComfyUI is released under the GNU General Public License v3.0 (GPLv3).
Your use of the ComfyUI software is governed by the GPLv3 license
terms. These Terms of Service govern your use of the hosted Services,
website, and Registry — not the open-source software itself.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 3. Using the Services -->
<div>
<h2 class="text-xl font-semibold text-white">3. Using the Services</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
You may use the Services only in compliance with these Terms and all
applicable laws. The Services are intended for users who are at least
18 years of age. By using the Services, you represent and warrant that
you meet this age requirement and have the legal capacity to enter into
these Terms.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 4. Your Responsibilities -->
<div>
<h2 class="text-xl font-semibold text-white">
4. Your Responsibilities
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
You are responsible for your use of the Services and any content you
create, share, or distribute through them. You agree to use the
Services in a manner that is lawful, respectful, and consistent with
these Terms. You are solely responsible for maintaining the security of
your account credentials.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 5. Use Restrictions -->
<div>
<h2 class="text-xl font-semibold text-white">5. Use Restrictions</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
You agree not to misuse the Services. This includes, but is not
limited to:
</p>
<ul class="mt-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-smoke-700">
<li>
Attempting to gain unauthorized access to any part of the Services
</li>
<li>
Using the Services to distribute malware, viruses, or harmful code
</li>
<li>
Interfering with or disrupting the integrity or performance of the
Services
</li>
<li>
Scraping, crawling, or using automated means to access the Services
without permission
</li>
<li>
Publishing custom nodes or workflows that contain malicious code or
violate third-party rights
</li>
</ul>
<!-- Full legal text to be added -->
</div>
<!-- 6. Accounts and User Information -->
<div>
<h2 class="text-xl font-semibold text-white">
6. Accounts and User Information
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
Certain features of the Services may require you to create an account.
You agree to provide accurate and complete information when creating
your account and to keep this information up to date. You are
responsible for all activity that occurs under your account. We reserve
the right to suspend or terminate accounts that violate these Terms.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 7. Intellectual Property Rights -->
<div>
<h2 class="text-xl font-semibold text-white">
7. Intellectual Property Rights
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
The Services, excluding open-source components, are owned by Comfy and
are protected by intellectual property laws. The Comfy name, logo, and
branding are trademarks of Comfy Org, Inc. You retain ownership of any
User Content you create. By submitting User Content to the Services,
you grant Comfy a non-exclusive, worldwide, royalty-free license to
host, display, and distribute such content as necessary to operate the
Services.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 8. Model and Workflow Distribution -->
<div>
<h2 class="text-xl font-semibold text-white">
8. Model and Workflow Distribution
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
When you distribute models, workflows, or custom nodes through the
Registry or Services, you represent that you have the right to
distribute such content and that it does not infringe any third-party
rights. You are responsible for specifying an appropriate license for
any content you distribute. Comfy does not claim ownership of content
distributed through the Registry.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 9. Fees and Payment -->
<div>
<h2 class="text-xl font-semibold text-white">9. Fees and Payment</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
Certain Services may be offered for a fee. If you choose to use paid
features, you agree to pay all applicable fees as described at the time
of purchase. Fees are non-refundable except as required by law or as
expressly stated in these Terms. Comfy reserves the right to change
pricing with reasonable notice.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 10. Term and Termination -->
<div>
<h2 class="text-xl font-semibold text-white">
10. Term and Termination
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
These Terms remain in effect while you use the Services. You may stop
using the Services at any time. Comfy may suspend or terminate your
access to the Services at any time, with or without cause and with or
without notice. Upon termination, your right to use the Services will
immediately cease. Sections that by their nature should survive
termination will continue to apply.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 11. Disclaimer of Warranties -->
<div>
<h2 class="text-xl font-semibold text-white">
11. Disclaimer of Warranties
</h2>
<p class="mt-4 text-sm uppercase leading-relaxed text-smoke-700">
The Services are provided "as is" and "as available" without warranties
of any kind, either express or implied, including but not limited to
implied warranties of merchantability, fitness for a particular
purpose, and non-infringement. Comfy does not warrant that the Services
will be uninterrupted, error-free, or secure.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 12. Limitation of Liability -->
<div>
<h2 class="text-xl font-semibold text-white">
12. Limitation of Liability
</h2>
<p class="mt-4 text-sm uppercase leading-relaxed text-smoke-700">
To the maximum extent permitted by law, Comfy shall not be liable for
any indirect, incidental, special, consequential, or punitive damages,
or any loss of profits or revenues, whether incurred directly or
indirectly, or any loss of data, use, goodwill, or other intangible
losses resulting from your use of the Services. Comfy's total liability
shall not exceed the amounts paid by you to Comfy in the twelve months
preceding the claim.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 13. Indemnification -->
<div>
<h2 class="text-xl font-semibold text-white">13. Indemnification</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
You agree to indemnify, defend, and hold harmless Comfy, its officers,
directors, employees, and agents from and against any claims,
liabilities, damages, losses, and expenses arising out of or in any
way connected with your access to or use of the Services, your User
Content, or your violation of these Terms.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 14. Governing Law and Dispute Resolution -->
<div>
<h2 class="text-xl font-semibold text-white">
14. Governing Law and Dispute Resolution
</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
These Terms shall be governed by and construed in accordance with the
laws of the State of Delaware, without regard to its conflict of laws
principles. Any disputes arising under these Terms shall be resolved
through binding arbitration in accordance with the rules of the
American Arbitration Association, except that either party may seek
injunctive relief in any court of competent jurisdiction.
</p>
<!-- Full legal text to be added -->
</div>
<!-- 15. Miscellaneous -->
<div>
<h2 class="text-xl font-semibold text-white">15. Miscellaneous</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
These Terms constitute the entire agreement between you and Comfy
regarding the Services. If any provision of these Terms is found to be
unenforceable, the remaining provisions will continue in effect. Our
failure to enforce any right or provision of these Terms will not be
considered a waiver. We may assign our rights under these Terms. You
may not assign your rights without our prior written consent.
</p>
<!-- Full legal text to be added -->
</div>
<!-- Contact -->
<div class="border-t border-white/10 pt-12">
<h2 class="text-xl font-semibold text-white">Contact Us</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
If you have questions about these Terms, please contact us at
<a
href="mailto:legal@comfy.org"
class="text-white underline transition-colors hover:text-smoke-700"
>
legal@comfy.org
</a>.
</p>
</div>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,176 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
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' },
]
const collaborators = [
{ name: 'Yogo', role: 'Collaborator' },
{ name: 'Fill (Machine Delusions)', role: 'Collaborator' },
{ name: 'Julien (MJM)', role: 'Collaborator' },
]
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.' },
]
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: 'Who is behind ComfyUI?',
a: 'ComfyUI was created by comfyanonymous and is maintained by a small, dedicated team of developers and community contributors.',
},
{
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: 'What are the future plans?',
a: 'We are focused on making ComfyUI the operating system for generative AI — improving performance, expanding model support, and building better tools for creators and developers.',
},
]
---
<BaseLayout title="关于我们 — Comfy" description="Learn about the team and mission behind ComfyUI, the open-source generative AI platform.">
<SiteNav client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
Crafting the next frontier of visual and audio media
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
An open-source community and company building the most powerful tools for generative AI creators.
</p>
</section>
<!-- Our Mission -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">Our Mission</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
We want to build the operating system for Gen 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.
</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>
<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">
<h3 class="text-lg font-semibold">{project.name}</h3>
<p class="mt-2 text-sm text-smoke-700">{project.description}</p>
</div>
))}
</div>
</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>
<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.
</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>
<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">
<div class="mx-auto h-16 w-16 rounded-full bg-charcoal-600" />
<h3 class="mt-4 font-semibold">{member.name}</h3>
<p class="mt-1 text-sm text-smoke-700">{member.role}</p>
</div>
))}
</div>
</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>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
<div class="mx-auto h-14 w-14 rounded-full bg-charcoal-600" />
<p class="mt-3 font-semibold">{person.name}</p>
</div>
))}
</div>
</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>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
<h3 class="text-lg font-semibold">{faq.q}</h3>
<p class="mt-2 text-smoke-700">{faq.a}</p>
</div>
))}
</div>
</div>
</section>
<!-- Join Our Team 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>
<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.
</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 />
</BaseLayout>

View File

@@ -0,0 +1,200 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const departments = [
{
name: '工程',
roles: [
{ title: 'Design Engineer', id: 'abc787b9-ad85-421c-8218-debd23bea096' },
{ title: 'Software Engineer, ComfyUI Desktop', id: 'ad2f76cb-a787-47d8-81c5-7e7f917747c0' },
{ title: 'Product Manager, ComfyUI (open-source)', id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c' },
{ title: 'Senior Software Engineer, Frontend Generalist', id: 'c3e0584d-5490-491f-aae4-b5922ef63fd2' },
{ title: 'Software Engineer, Frontend Generalist', id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40' },
{ title: 'Tech Lead Manager, Frontend', id: 'a0665088-3314-457a-aa7b-12ca5c3eb261' },
{ title: 'Senior Software Engineer, Comfy Cloud', id: '27cdf4ce-69a4-44da-b0ec-d54875fd14a1' },
{ title: 'Senior Applied AI/ML Engineer', id: '5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0' },
{ title: 'Senior Engineer, Backend Generalist', id: '732f8b39-076d-4847-afe3-f54d4451607e' },
{ title: 'Software Engineer, Core ComfyUI Contributor', id: '7d4062d6-d500-445a-9a5f-014971af259f' },
],
},
{
name: '设计',
roles: [
{ title: 'Graphic Designer', id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f' },
{ title: 'Creative Artist', id: '19ba10aa-4961-45e8-8473-66a8a7a8079d' },
{ title: 'Senior Product Designer', id: 'b2e864c6-4754-4e04-8f46-1022baa103c3' },
{ title: 'Freelance Motion Designer', id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b' },
],
},
{
name: '市场营销',
roles: [
{ title: 'Partnership & Events Marketing Manager', id: '89d3ff75-2055-4e92-9c69-81feff55627c' },
{ title: 'Lifecycle Growth Marketer', id: 'be74d210-3b50-408c-9f61-8fee8833ce64' },
{ title: 'Social Media Manager', id: '28dea965-662b-4786-b024-c9a1b6bc1f23' },
],
},
{
name: '商务运营/增长',
roles: [
{ title: 'Founding Account Executive', id: '061ff83a-fe18-40f7-a5c4-4ce7da7086a6' },
{ title: 'Senior Technical Recruiter', id: 'd5008532-c45d-46e6-ba2c-20489d364362' },
],
},
]
const whyJoinUs = [
'你想打造赋能他人创作的工具。',
'你喜欢从事已经在驱动真实世界视频、图像、音乐和应用的基础技术。',
'你关心开放、免费的替代方案,而非封闭的 AI 平台。',
'你相信艺术家、黑客和独立创作者应该真正掌控自己的工具。',
'你想在一个精干、务实、快速迭代的小团队中工作。',
]
const notAFit = [
'你需要一切都计划好。我们边做边摸索,方向变化很快。如果不确定性让你焦虑,你可能不会喜欢这里。',
'你只把自己定位为程序员/[某个职位]。在这里,每个人都会做各种事情——和用户交流、写文档,什么需要做就做什么。我们需要主动补位的人。',
'你需要完善的流程。我们在做一件革命性的事情,这意味着要自己写规则。如果你需要清晰的结构,这可能会让你抓狂。',
'你喜欢经理来检查你的工作。我们信任每个人端到端地负责自己的工作。看到问题就修复——不需要请示。',
'你倾向于等到掌握所有信息再行动。我们经常需要在信息不完整的情况下做决定,然后在过程中调整。分析瘫痪在这里行不通。',
'你有很大的自我。我们追求突破边界和解决难题,而不是个人英雄主义。如果你无法接受直接的反馈或从错误中学习,这里不适合你。',
]
const questions = [
'团队文化是什么样的?',
'我需要什么样的背景才能申请?',
'如何申请?',
'招聘流程是什么样的?',
'线下还是远程?',
'如何提高获得工作的机会?',
'如果我需要签证担保在美国工作怎么办?',
'我能获得简历和面试的反馈吗?',
]
---
<BaseLayout
title="招聘 — Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<SiteNav client:load />
<main>
<!-- Hero -->
<section class="px-6 pb-24 pt-40">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-4xl font-bold tracking-tight md:text-6xl">
构建生成式 AI 的
<br />
<span class="text-brand-yellow">&ldquo;操作系统&rdquo;</span>
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
我们是全球领先的<strong class="text-white">视觉 AI 平台</strong>——一个开放、模块化的系统,任何人都可以精确地构建、
定制和自动化 AI 工作流,并拥有完全的控制权。与大多数将内部机制隐藏在简单提示框后面的 AI 工具不同,我们赋予专业人士
<strong class="text-white">设计自己管线的自由</strong>——像积木一样将模型、工具和逻辑可视化地连接起来。
</p>
<p class="mx-auto mt-4 max-w-2xl text-lg leading-relaxed text-smoke-700">
ComfyUI 被<strong class="text-white">艺术家、电影人、游戏创作者、设计师、研究人员、视效公司</strong>等使用,
其中包括 <strong class="text-white">OpenAI、Netflix、Amazon Studios、育碧、EA 和腾讯</strong>的团队——他们都想超越预设,
真正塑造 AI 的创作方式。
</p>
</div>
</section>
<!-- Job Listings -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl">
{departments.map((dept) => (
<div class="mb-16 last:mb-0">
<h2 class="mb-6 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
{dept.name}
</h2>
<div class="space-y-3">
{dept.roles.map((role) => (
<a
href={`https://jobs.ashbyhq.com/comfy-org/${role.id}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-between rounded-lg border border-white/10 px-5 py-4 transition-colors hover:border-brand-yellow"
>
<span class="font-medium">{role.title}</span>
<span class="text-sm text-smoke-700">→</span>
</a>
))}
</div>
</div>
))}
</div>
</section>
<!-- Why Join Us / When It's Not a Fit — 2-column layout -->
<section class="border-t border-white/10 bg-charcoal-800 px-6 py-24">
<div class="mx-auto grid max-w-5xl gap-16 md:grid-cols-2">
<!-- Why Join Us -->
<div>
<h2 class="mb-8 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
为什么加入我们?
</h2>
<ul class="space-y-4">
{whyJoinUs.map((item) => (
<li class="flex gap-3 text-smoke-700">
<span class="mt-1 text-brand-yellow">•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
<!-- When It's Not a Fit -->
<div>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-widest text-white">
不太适合的情况
</h2>
<p class="mb-8 text-sm text-smoke-700">
在 Comfy Org 工作并不适合所有人,这完全没问题。如果以下情况符合你,你可能需要考虑其他机会:
</p>
<ul class="space-y-4">
{notAFit.map((item) => (
<li class="flex gap-3 text-sm text-smoke-700">
<span class="mt-0.5 text-white/40">—</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
<!-- Q&A -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl">
<h2 class="mb-10 text-sm font-semibold uppercase tracking-widest text-brand-yellow">
常见问题
</h2>
<ul class="space-y-4">
{questions.map((q) => (
<li class="rounded-lg border border-white/10 px-5 py-4 font-medium">
{q}
</li>
))}
</ul>
</div>
</section>
<!-- Contact CTA -->
<section class="border-t border-white/10 px-6 py-24">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-3xl font-bold">有问题?联系我们!</h2>
<a
href="https://support.comfy.org/"
target="_blank"
rel="noopener noreferrer"
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"
>
联系我们
</a>
</div>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,80 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
const cards = [
{
icon: '🪟',
title: 'Windows',
description: '需要 NVIDIA 或 AMD 显卡',
cta: '下载 Windows 版',
href: 'https://download.comfy.org/windows/nsis/x64',
outlined: false,
},
{
icon: '🍎',
title: 'Mac',
description: '需要 Apple Silicon (M 系列)',
cta: '下载 Mac 版',
href: 'https://download.comfy.org/mac/dmg/arm64',
outlined: false,
},
{
icon: '🐙',
title: 'GitHub',
description: '在任何平台上从源码构建',
cta: '从 GitHub 安装',
href: 'https://github.com/comfyanonymous/ComfyUI',
outlined: true,
},
]
---
<BaseLayout title="下载 — Comfy">
<SiteNav client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
</h1>
<p class="mt-4 text-lg text-smoke-700">
在本地体验 AI 创作
</p>
<div class="mt-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{cards.map((card) => (
<a
href={card.href}
class="flex flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
>
<span class="text-4xl" aria-hidden="true">{card.icon}</span>
<h2 class="mt-4 text-xl font-semibold text-white">{card.title}</h2>
<p class="mt-2 text-sm text-smoke-700">{card.description}</p>
<span
class:list={[
'mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90',
card.outlined
? 'border border-brand-yellow text-brand-yellow'
: 'bg-brand-yellow text-black',
]}
>
{card.cta}
</span>
</a>
))}
</div>
<div class="mt-20 rounded-xl border border-white/10 bg-charcoal-800 p-8">
<p class="text-lg text-smoke-700">
没有 GPU{' '}
<a
href="https://app.comfy.org"
class="font-semibold text-brand-yellow hover:underline"
>
试试 Comfy Cloud →
</a>
</p>
</div>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout title="作品集 — Comfy">
<SiteNav client:load />
<main class="bg-black text-white">
<!-- Hero -->
<section class="mx-auto max-w-5xl px-6 pb-16 pt-32 text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
在 ComfyUI 中<span class="text-brand-yellow">构建、调整与梦想</span>
</h1>
<p class="mx-auto mt-4 max-w-2xl text-lg text-smoke-700">
社区使用 ComfyUI 创作的精彩作品一瞥。
</p>
</section>
<!-- Placeholder Grid -->
<section class="mx-auto max-w-6xl px-6 pb-24">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map(() => (
<div class="flex aspect-video items-center justify-center rounded-xl border border-white/10 bg-charcoal-600">
<p class="text-sm text-smoke-700">社区展示即将上线</p>
</div>
))}
</div>
</section>
<!-- CTA -->
<section class="mx-auto max-w-3xl px-6 pb-32 text-center">
<h2 class="text-2xl font-semibold">有很酷的作品想分享?</h2>
<a
href="https://support.comfy.org/"
class="mt-6 inline-block rounded-full bg-brand-yellow px-8 py-3 font-medium text-black transition hover:opacity-90"
>
联系我们
</a>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,233 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout
title="隐私政策 — Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24">
<h1 class="text-3xl font-bold text-white">隐私政策</h1>
<p class="mt-2 text-sm text-smoke-500">生效日期2025年4月18日</p>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">简介</h2>
<p class="text-sm leading-relaxed text-smoke-500">
您的隐私对我们非常重要。本隐私政策说明了 Comfy Org, Inc."Comfy"、
"我们")在您使用我们位于 comfy.org 的网站及相关服务时,如何收集、使用、
共享和保护您的个人信息。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
我们收集的信息
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
当您与我们的服务互动时,我们可能会收集以下个人信息:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>姓名</li>
<li>电子邮件地址</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
我们在您自愿提供信息时收集这些信息,例如当您创建账户、订阅通讯或联系客服时。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
处理您个人信息的合法理由
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们仅在有合法理由时才收集和使用您的个人信息。我们处理个人信息是为了提供、改进和管理我们的服务;与您沟通;确保安全和防止欺诈;以及遵守适用法律。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
我们保留您信息的时间
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们仅在实现本政策所述目的所必需的期限内保留您的个人信息,除非法律要求或允许更长的保留期限。当我们不再需要您的信息时,我们将安全地删除或匿名化处理。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">儿童隐私</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们不会故意收集13岁以下儿童的个人信息。如果我们发现已收集了13岁以下儿童的个人信息我们将尽快采取措施删除该等信息。如果您认为我们收集了13岁以下儿童的信息请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
向第三方披露个人信息
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们可能会向协助我们运营服务的第三方服务提供商披露个人信息。这包括 Stripe
等支付处理商、云托管提供商和分析服务。我们要求这些方按照本政策和适用法律处理您的数据。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
您的权利及控制您的个人信息
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
根据您所在的地区,您可能拥有以下关于您个人信息的权利:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>访问我们持有的关于您的个人信息的权利。</li>
<li>请求更正不准确的个人信息的权利。</li>
<li>请求删除您的个人信息的权利。</li>
<li>反对或限制处理的权利。</li>
<li>数据可携带权。</li>
<li>随时撤回同意的权利。</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
如需行使任何这些权利,请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
本政策的局限性
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们的网站可能链接到非我们运营的外部网站。请注意,我们无法控制这些网站的内容和做法,也无法对其各自的隐私政策承担责任。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
本政策的变更
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们可能会不时更新本隐私政策,以反映我们做法的变化或出于其他运营、法律或监管原因。我们将通过在网站上发布更新后的政策并注明修订后的生效日期来通知您任何重大变更。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
美国各州隐私合规
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
我们在适用的情况下遵守以下美国各州的隐私法:
</p>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-smoke-500">
<li>加利福尼亚州CCPA / CPRA</li>
<li>科罗拉多州CPA</li>
<li>特拉华州DPDPA</li>
<li>佛罗里达州FDBR</li>
<li>弗吉尼亚州VCDPA</li>
<li>犹他州UCPA</li>
</ul>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
这些州的居民可能拥有额外的权利,包括了解收集了哪些个人信息的权利、删除个人信息的权利以及选择退出出售个人信息的权利。如需行使这些权利,请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
请勿追踪Do Not Track
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
某些浏览器包含"请勿追踪"DNT功能向网站发出您不希望被追踪的信号。目前尚无关于公司应如何回应
DNT 信号的统一标准。目前,我们不会回应 DNT 信号。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">CCPA / CPPA</h2>
<p class="text-sm leading-relaxed text-smoke-500">
根据加利福尼亚消费者隐私法CCPA和加利福尼亚隐私保护局CPPA的规定加利福尼亚州居民有权了解我们收集的个人信息、请求删除其数据以及选择退出出售其个人信息。我们不会出售个人信息。如需提出请求请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
GDPR — 欧洲经济区
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
如果您位于欧洲经济区EEA《通用数据保护条例》GDPR赋予您有关个人数据的某些权利包括访问权、更正权、删除权、限制处理权、数据可携带权以及反对处理权。我们处理的法律依据包括同意、合同履行和合法利益。如需行使您的
GDPR 权利,请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">英国 GDPR</h2>
<p class="text-sm leading-relaxed text-smoke-500">
如果您位于英国英国《通用数据保护条例》UK
GDPR为您提供与欧盟 GDPR 类似的权利,包括访问、更正、删除和传输数据的权利。如需行使您在英国
GDPR 下的权利,请通过
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
与我们联系。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">
澳大利亚隐私
</h2>
<p class="text-sm leading-relaxed text-smoke-500">
如果您位于澳大利亚《1988年隐私法》下的澳大利亚隐私原则APPs适用于我们对您个人信息的处理。您有权请求访问和更正您的个人信息。如果您认为我们违反了
APPs您可以向我们或澳大利亚信息专员办公室OAIC提出投诉。
</p>
<!-- Full legal text to be added -->
</section>
<section>
<h2 class="mt-12 mb-4 text-xl font-semibold text-white">联系我们</h2>
<p class="text-sm leading-relaxed text-smoke-500">
如果您对本隐私政策或我们的数据处理方式有任何疑问或顾虑,请通过以下方式联系我们:
</p>
<p class="mt-3 text-sm leading-relaxed text-smoke-500">
<a href="mailto:support@comfy.org" class="text-white underline">
support@comfy.org
</a>
</p>
</section>
</main>
<SiteFooter />
</BaseLayout>

View File

@@ -0,0 +1,220 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/SiteNav.vue'
import SiteFooter from '../../components/SiteFooter.vue'
---
<BaseLayout
title="服务条款 — Comfy"
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<SiteNav client:load />
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
<header class="mb-16">
<h1 class="text-3xl font-bold text-white">服务条款</h1>
<p class="mt-2 text-lg text-smoke-700">适用于 ComfyUI</p>
<p class="mt-4 text-sm text-smoke-700">
生效日期2025 年 3 月 1 日 · 最后更新2025 年 3 月 1 日
</p>
</header>
<section class="space-y-12">
<!-- 引言 -->
<div>
<p class="text-sm leading-relaxed text-smoke-700">
欢迎使用 ComfyUI 及 Comfy Org, Inc.(以下简称"Comfy"、"我们")提供的服务。访问或使用
ComfyUI、Comfy Registry、comfy.org 或我们的任何相关服务(统称"服务")即表示您同意受本服务条款("条款")的约束。如果您不同意本条款,请勿使用本服务。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 1. 定义 -->
<div>
<h2 class="text-xl font-semibold text-white">1. 定义</h2>
<ul class="mt-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-smoke-700">
<li>
<strong class="text-white">"ComfyUI"</strong>
指用于 AI 驱动内容生成的开源节点式可视化编程界面。
</li>
<li>
<strong class="text-white">"服务"</strong>
指 ComfyUI、Comfy Registry、comfy.org 网站、API、文档及 Comfy 运营的任何相关服务。
</li>
<li>
<strong class="text-white">"用户内容"</strong>
指您通过服务创建、上传或共享的任何内容、工作流、自定义节点、模型或其他材料。
</li>
<li>
<strong class="text-white">"Registry"</strong>
指 Comfy Registry用于分发自定义节点和扩展的软件包仓库。
</li>
</ul>
<!-- Full legal text to be added -->
</div>
<!-- 2. ComfyUI 软件许可 -->
<div>
<h2 class="text-xl font-semibold text-white">2. ComfyUI 软件许可</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
ComfyUI 基于 GNU 通用公共许可证 v3.0 (GPLv3) 发布。您对 ComfyUI
软件的使用受 GPLv3 许可条款的约束。本服务条款管辖您对托管服务、网站和
Registry 的使用,而非开源软件本身。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 3. 使用服务 -->
<div>
<h2 class="text-xl font-semibold text-white">3. 使用服务</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
您仅可在遵守本条款和所有适用法律的前提下使用服务。服务仅供年满 18
周岁的用户使用。使用服务即表示您声明并保证您符合此年龄要求,并有签订本条款的法律行为能力。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 4. 您的责任 -->
<div>
<h2 class="text-xl font-semibold text-white">4. 您的责任</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
您应对使用服务以及通过服务创建、共享或分发的任何内容负责。您同意以合法、尊重他人且符合本条款的方式使用服务。您全权负责维护账户凭据的安全。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 5. 使用限制 -->
<div>
<h2 class="text-xl font-semibold text-white">5. 使用限制</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
您同意不滥用服务,包括但不限于:
</p>
<ul class="mt-4 list-disc space-y-2 pl-5 text-sm leading-relaxed text-smoke-700">
<li>试图未经授权访问服务的任何部分</li>
<li>利用服务传播恶意软件、病毒或有害代码</li>
<li>干扰或破坏服务的完整性或性能</li>
<li>未经许可使用自动化手段抓取或爬取服务</li>
<li>发布包含恶意代码或侵犯第三方权利的自定义节点或工作流</li>
</ul>
<!-- Full legal text to be added -->
</div>
<!-- 6. 账户和用户信息 -->
<div>
<h2 class="text-xl font-semibold text-white">6. 账户和用户信息</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
服务的某些功能可能要求您创建账户。您同意在创建账户时提供准确、完整的信息,并及时更新。您对账户下发生的所有活动负责。我们保留暂停或终止违反本条款的账户的权利。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 7. 知识产权 -->
<div>
<h2 class="text-xl font-semibold text-white">7. 知识产权</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
除开源组件外,服务归 Comfy 所有并受知识产权法保护。Comfy 名称、标志和品牌是 Comfy Org, Inc.
的商标。您保留您创建的任何用户内容的所有权。向服务提交用户内容即表示您授予 Comfy
一项非排他性、全球性、免版税的许可,以在运营服务所需的范围内托管、展示和分发此类内容。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 8. 模型和工作流分发 -->
<div>
<h2 class="text-xl font-semibold text-white">8. 模型和工作流分发</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
当您通过 Registry
或服务分发模型、工作流或自定义节点时您声明您有权分发此类内容且其不侵犯任何第三方权利。您有责任为分发的内容指定适当的许可证。Comfy
不主张对通过 Registry 分发的内容的所有权。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 9. 费用和付款 -->
<div>
<h2 class="text-xl font-semibold text-white">9. 费用和付款</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
某些服务可能需要付费。如果您选择使用付费功能则同意支付购买时所述的所有适用费用。除法律要求或本条款明确规定外费用不予退还。Comfy
保留在合理通知后变更定价的权利。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 10. 期限和终止 -->
<div>
<h2 class="text-xl font-semibold text-white">10. 期限和终止</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
在您使用服务期间本条款持续有效。您可随时停止使用服务。Comfy
可随时暂停或终止您对服务的访问,无论是否有原因,也无论是否事先通知。终止后,您使用服务的权利将立即终止。按其性质应在终止后继续有效的条款将继续适用。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 11. 免责声明 -->
<div>
<h2 class="text-xl font-semibold text-white">11. 免责声明</h2>
<p class="mt-4 text-sm uppercase leading-relaxed text-smoke-700">
服务按"现状"和"可用"基础提供不附带任何形式的明示或暗示保证包括但不限于对适销性、特定用途适用性和非侵权性的暗示保证。Comfy
不保证服务将不间断、无错误或安全。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 12. 责任限制 -->
<div>
<h2 class="text-xl font-semibold text-white">12. 责任限制</h2>
<p class="mt-4 text-sm uppercase leading-relaxed text-smoke-700">
在法律允许的最大范围内Comfy
不对任何间接、附带、特殊、后果性或惩罚性损害或任何利润或收入损失无论是直接还是间接产生的或任何数据、使用、商誉或其他无形损失承担责任。Comfy
的总责任不超过您在索赔前十二个月内向 Comfy 支付的金额。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 13. 赔偿 -->
<div>
<h2 class="text-xl font-semibold text-white">13. 赔偿</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
您同意赔偿、辩护并使 Comfy
及其管理人员、董事、员工和代理人免受因您访问或使用服务、您的用户内容或您违反本条款而产生的或与之相关的任何索赔、责任、损害、损失和费用。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 14. 适用法律和争议解决 -->
<div>
<h2 class="text-xl font-semibold text-white">14. 适用法律和争议解决</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
本条款受特拉华州法律管辖并据其解释,不适用其冲突法原则。因本条款引起的任何争议应根据美国仲裁协会的规则通过有约束力的仲裁解决,但任何一方均可在有管辖权的法院寻求禁令救济。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 15. 其他 -->
<div>
<h2 class="text-xl font-semibold text-white">15. 其他</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
本条款构成您与 Comfy
之间关于服务的完整协议。如果本条款的任何条款被认定为不可执行,其余条款将继续有效。我们未能执行本条款的任何权利或条款不构成放弃。我们可以转让本条款下的权利。未经我们事先书面同意,您不得转让您的权利。
</p>
<!-- Full legal text to be added -->
</div>
<!-- 联系我们 -->
<div class="border-t border-white/10 pt-12">
<h2 class="text-xl font-semibold text-white">联系我们</h2>
<p class="mt-4 text-sm leading-relaxed text-smoke-700">
如果您对本条款有任何疑问,请通过
<a
href="mailto:legal@comfy.org"
class="text-white underline transition-colors hover:text-smoke-700"
>
legal@comfy.org
</a>
与我们联系。
</p>
</div>
</section>
</main>
<SiteFooter />
</BaseLayout>

26
apps/website/vercel.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm --filter @comfyorg/website build",
"outputDirectory": "apps/website/dist",
"installCommand": "pnpm install --frozen-lockfile",
"framework": null,
"redirects": [
{
"source": "/pricing",
"destination": "/cloud/pricing",
"permanent": true
},
{
"source": "/enterprise",
"destination": "/cloud/enterprise",
"permanent": true
},
{
"source": "/blog",
"destination": "https://blog.comfy.org/",
"permanent": true
},
{ "source": "/contact", "destination": "/about", "permanent": true },
{ "source": "/press", "destination": "/about", "permanent": true }
]
}

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 12,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_aaa.png", "image"]
},
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_bbb.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -14,12 +14,17 @@ import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
import { ConfirmDialog } from '@e2e/fixtures/components/ConfirmDialog'
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import {
AssetsSidebarTab,
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -31,6 +36,7 @@ import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { ModelLibraryHelper } from '@e2e/fixtures/helpers/ModelLibraryHelper'
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
@@ -55,7 +61,9 @@ class ComfyPropertiesPanel {
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _topbar: Topbar | null = null
@@ -73,11 +81,21 @@ class ComfyMenu {
return this.sideToolbar.locator('.side-bar-button')
}
get modelLibraryTab() {
this._modelLibraryTab ??= new ModelLibrarySidebarTab(this.page)
return this._modelLibraryTab
}
get nodeLibraryTab() {
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
return this._nodeLibraryTab
}
get nodeLibraryTabV2() {
this._nodeLibraryTabV2 ??= new NodeLibrarySidebarTabV2(this.page)
return this._nodeLibraryTabV2
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
@@ -116,48 +134,6 @@ class ComfyMenu {
}
}
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -181,6 +157,8 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
@@ -199,6 +177,7 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly modelLibrary: ModelLibraryHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -228,6 +207,8 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
@@ -246,6 +227,7 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
}
get visibleToasts() {

View File

@@ -1,11 +1,14 @@
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
readonly root: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
}
get header() {
return this.page
.getByRole('dialog')
return this.root
.locator('div')
.filter({ hasText: 'Add node filter condition' })
}

View File

@@ -0,0 +1,44 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
export class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
public readonly save: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
this.save = this.root.getByRole('button', { name: 'Save' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for this confirm dialog to close (not all dialogs — another
// dialog like save-as may open immediately after).
await this.root.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}

View File

@@ -0,0 +1,11 @@
import type { Locator, Page } from '@playwright/test'
export class MediaLightbox {
public readonly root: Locator
public readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.closeButton = this.root.getByLabel('Close')
}
}

View File

@@ -100,6 +100,59 @@ export class NodeLibrarySidebarTab extends SidebarTab {
}
}
export class NodeLibrarySidebarTabV2 extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'node-library')
}
get searchInput() {
return this.page.getByPlaceholder('Search...')
}
get sidebarContent() {
return this.page.locator('.sidebar-content-container')
}
getTab(name: string) {
return this.sidebarContent.getByRole('tab', { name, exact: true })
}
get allTab() {
return this.getTab('All')
}
get blueprintsTab() {
return this.getTab('Blueprints')
}
get sortButton() {
return this.sidebarContent.getByRole('button', { name: 'Sort' })
}
getFolder(folderName: string) {
return this.sidebarContent
.getByRole('treeitem', { name: folderName })
.first()
}
getNode(nodeName: string) {
return this.sidebarContent.getByRole('treeitem', { name: nodeName }).first()
}
async expandFolder(folderName: string) {
const folder = this.getFolder(folderName)
const isExpanded = await folder.getAttribute('aria-expanded')
if (isExpanded !== 'true') {
await folder.click()
}
}
override async open() {
await super.open()
await this.searchInput.waitFor({ state: 'visible' })
}
}
export class WorkflowsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'workflows')
@@ -170,6 +223,59 @@ export class WorkflowsSidebarTab extends SidebarTab {
}
}
export class ModelLibrarySidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'model-library')
}
get searchInput() {
return this.page.getByPlaceholder('Search Models...')
}
get modelTree() {
return this.page.locator('.model-lib-tree-explorer')
}
get refreshButton() {
return this.page.getByRole('button', { name: 'Refresh' })
}
get loadAllFoldersButton() {
return this.page.getByRole('button', { name: 'Load All Folders' })
}
get folderNodes() {
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
}
get leafNodes() {
return this.modelTree.locator('.p-tree-node-leaf')
}
get modelPreview() {
return this.page.locator('.model-lib-model-preview')
}
override async open() {
await super.open()
await this.modelTree.waitFor({ state: 'visible' })
}
getFolderByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node:not(.p-tree-node-leaf)')
.filter({ hasText: label })
.first()
}
getLeafByLabel(label: string) {
return this.modelTree
.locator('.p-tree-node-leaf')
.filter({ hasText: label })
.first()
}
}
export class AssetsSidebarTab extends SidebarTab {
constructor(public override readonly page: Page) {
super(page, 'assets')

View File

@@ -0,0 +1,19 @@
import type { Locator, Page } from '@playwright/test'
export class TemplatesDialog {
public readonly root: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
}
filterByHeading(name: string): Locator {
return this.root.filter({
has: this.page.getByRole('heading', { name, exact: true })
})
}
getCombobox(name: RegExp | string): Locator {
return this.root.getByRole('combobox', { name })
}
}

View File

@@ -50,15 +50,30 @@ export class Topbar {
return classes ? !classes.includes('invisible') : false
}
get newWorkflowButton(): Locator {
return this.page.locator('.new-blank-workflow-button')
}
getWorkflowTab(tabName: string): Locator {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
getTab(index: number): Locator {
return this.page.locator('.workflow-tabs .p-togglebutton').nth(index)
}
getActiveTab(): Locator {
return this.page.locator(
'.workflow-tabs .p-togglebutton.p-togglebutton-checked'
)
}
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
await tab.hover()
await tab.locator('.close-button').click({ force: true })
}
getSaveDialog(): Locator {

View File

@@ -83,6 +83,14 @@ export class AppModeHelper {
return this.page.locator('[data-testid="linear-widgets"]')
}
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
get imagePickerPopover(): Locator {
return this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -1,20 +1,22 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now() / 1000
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
@@ -33,13 +35,13 @@ export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now() / 1000
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60,
execution_start_time: now - i * 60,
execution_end_time: now - i * 60 + 5 + i,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
@@ -88,6 +90,8 @@ export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
@@ -143,18 +147,23 @@ export class AssetsHelper {
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
body: JSON.stringify(response)
})
}
@@ -179,6 +188,36 @@ export class AssetsHelper {
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
/**
* Mock the POST /api/history endpoint used for deleting history items.
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
* the in-memory mock state so subsequent /api/jobs fetches reflect the
* deletion.
*/
async mockDeleteHistory(): Promise<void> {
if (this.deleteHistoryRouteHandler) return
this.deleteHistoryRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const body = request.postDataJSON() as { delete?: string[] }
if (body.delete) {
const idsToRemove = new Set(body.delete)
this.generatedJobs = this.generatedJobs.filter(
(job) => !idsToRemove.has(job.id)
)
}
await route.fulfill({ status: 200, body: '{}' })
}
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
@@ -200,5 +239,13 @@ export class AssetsHelper {
)
this.inputFilesRouteHandler = null
}
if (this.deleteHistoryRouteHandler) {
await this.page.unroute(
historyRoutePattern,
this.deleteHistoryRouteHandler
)
this.deleteHistoryRouteHandler = null
}
}
}

View File

@@ -0,0 +1,134 @@
import type { Page, Route } from '@playwright/test'
import type {
ModelFile,
ModelFolderInfo
} from '../../../src/platform/assets/schemas/assetSchema'
const modelFoldersRoutePattern = /\/api\/experiment\/models$/
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
export interface MockModelMetadata {
'modelspec.title'?: string
'modelspec.author'?: string
'modelspec.architecture'?: string
'modelspec.description'?: string
'modelspec.resolution'?: string
'modelspec.tags'?: string
}
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
return names.map((name) => ({ name, folders: [] }))
}
export function createMockModelFiles(
filenames: string[],
pathIndex = 0
): ModelFile[] {
return filenames.map((name) => ({ name, pathIndex }))
}
export class ModelLibraryHelper {
private foldersRouteHandler: ((route: Route) => Promise<void>) | null = null
private filesRouteHandler: ((route: Route) => Promise<void>) | null = null
private metadataRouteHandler: ((route: Route) => Promise<void>) | null = null
private folders: ModelFolderInfo[] = []
private filesByFolder: Record<string, ModelFile[]> = {}
private metadataByModel: Record<string, MockModelMetadata> = {}
constructor(private readonly page: Page) {}
async mockModelFolders(folders: ModelFolderInfo[]): Promise<void> {
this.folders = [...folders]
if (this.foldersRouteHandler) return
this.foldersRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.folders)
})
}
await this.page.route(modelFoldersRoutePattern, this.foldersRouteHandler)
}
async mockModelFiles(folder: string, files: ModelFile[]): Promise<void> {
this.filesByFolder[folder] = [...files]
if (this.filesRouteHandler) return
this.filesRouteHandler = async (route: Route) => {
const match = route.request().url().match(modelFilesRoutePattern)
const folderName = match?.[1] ? decodeURIComponent(match[1]) : undefined
const files = folderName ? (this.filesByFolder[folderName] ?? []) : []
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(files)
})
}
await this.page.route(modelFilesRoutePattern, this.filesRouteHandler)
}
async mockMetadata(
entries: Record<string, MockModelMetadata>
): Promise<void> {
Object.assign(this.metadataByModel, entries)
if (this.metadataRouteHandler) return
this.metadataRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename') ?? ''
const metadata = this.metadataByModel[filename]
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(metadata ?? {})
})
}
await this.page.route(viewMetadataRoutePattern, this.metadataRouteHandler)
}
async mockFoldersWithFiles(config: Record<string, string[]>): Promise<void> {
const folderNames = Object.keys(config)
await this.mockModelFolders(createMockModelFolders(folderNames))
for (const [folder, files] of Object.entries(config)) {
await this.mockModelFiles(folder, createMockModelFiles(files))
}
}
async clearMocks(): Promise<void> {
this.folders = []
this.filesByFolder = {}
this.metadataByModel = {}
if (this.foldersRouteHandler) {
await this.page.unroute(
modelFoldersRoutePattern,
this.foldersRouteHandler
)
this.foldersRouteHandler = null
}
if (this.filesRouteHandler) {
await this.page.unroute(modelFilesRoutePattern, this.filesRouteHandler)
this.filesRouteHandler = null
}
if (this.metadataRouteHandler) {
await this.page.unroute(
viewMetadataRoutePattern,
this.metadataRouteHandler
)
this.metadataRouteHandler = null
}
}
}

View File

@@ -4,7 +4,10 @@ import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
NodeId
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
@@ -111,6 +114,27 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -185,3 +209,13 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -0,0 +1,112 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
const SELECTION_PADDING = 10
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
// For collapsed nodes, use DOM element size (matches selectionBorder.ts
// which reads layoutStore.collapsedSize in Vue mode)
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const el =
id && isCollapsed
? document.querySelector(`[data-node-id="${id}"]`)
: null
const w = el instanceof HTMLElement ? el.offsetWidth : rect[2]
const h = el instanceof HTMLElement ? el.offsetHeight : rect[3]
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + w)
maxY = Math.max(maxY, rect[1] + h)
}
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
// Legacy mode: no Vue DOM element, use boundingRect directly
if (!nodeEl) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (node) {
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_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -44,7 +44,15 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
missingModelsGroup: 'error-group-missing-model',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -61,8 +69,21 @@ export const TestIds = {
propertiesPanel: {
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
},
node: {
titleInput: 'node-title-input'
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
@@ -70,6 +91,9 @@ export const TestIds = {
colorBlue: 'blue',
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -131,5 +155,7 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -332,6 +332,18 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/** Deterministic setter using node.collapse() API (not a toggle). */
async setCollapsed(collapsed: boolean) {
await this.comfyPage.page.evaluate(
([id, collapsed]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
if (node.collapsed !== collapsed) node.collapse(true)
},
[this.id, collapsed] as const
)
await this.comfyPage.nextFrame()
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -1,5 +1,7 @@
import type { Locator } from '@playwright/test'
import { TestIds } from '../selectors'
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
constructor(private readonly locator: Locator) {}
@@ -20,6 +22,10 @@ export class VueNodeFixture {
return this.locator.locator('[data-testid^="node-body-"]')
}
get pinIndicator(): Locator {
return this.locator.getByTestId(TestIds.node.pinIndicator)
}
get collapseButton(): Locator {
return this.locator.locator('[data-testid="node-collapse-button"]')
}

View File

@@ -145,10 +145,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
const popover = comfyPage.appMode.imagePickerPopover
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {

View File

@@ -18,15 +18,13 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const { root, confirm, reject } = comfyPage.confirmDialog
await expect(root).toBeVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
await expect(confirm).toBeVisible()
await expect(confirm).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
await expect(reject).toBeVisible()
await expect(reject).toBeInViewport()
})
})

View File

@@ -0,0 +1,225 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing required inputs/i)
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
})
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow (2-step confirm)', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).not.toBeVisible()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).not.toBeVisible()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).not.toBeVisible()
})
})
test.describe('Locate node', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLocateButton
)
await expect(locateButton).toBeVisible()
await locateButton.click()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})
})

View File

@@ -0,0 +1,111 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
outputs_count: 2
}),
createMockJob({
id: 'job-completed-2',
status: 'completed',
create_time: now - 120_000,
execution_start_time: now - 120_000,
execution_end_time: now - 115_000,
outputs_count: 1
}),
createMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
// Expanded overlay should show job items
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
})
test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(
comfyPage.page.getByRole('button', { name: 'All', exact: true })
).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Completed', exact: true })
).toBeVisible()
})
test('Overlay shows Failed tab when failed jobs exist', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Failed', exact: true })
).toBeVisible()
})
test('Completed filter shows only completed jobs', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
await expect(
comfyPage.page.locator('[data-job-id="job-completed-1"]')
).toBeVisible()
await expect(
comfyPage.page.locator('[data-job-id="job-failed-1"]')
).not.toBeVisible()
})
test('Toggling overlay again closes it', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await toggle.click()
await expect(
comfyPage.page.locator('[data-job-id]').first()
).not.toBeVisible()
})
})

View File

@@ -41,30 +41,28 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
const { root } = comfyPage.mediaLightbox
await expect(root).toBeVisible()
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
await runAndOpenGallery(comfyPage)
await expect(comfyPage.mediaLightbox.closeButton).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
})
})

View File

@@ -0,0 +1,151 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { measureSelectionBounds } from '../fixtures/helpers/boundsUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [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 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 nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
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]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
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.setCollapsed(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
}
}
)
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 nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const state of nodeStates) {
for (const pos of positions) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.setCollapsed(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
}
)

View File

@@ -1,9 +1,20 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
const BYPASS_CLASS = /before:bg-bypass\/60/
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
@@ -36,7 +47,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -51,14 +62,12 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
@@ -71,7 +80,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
comfyPage.page.getByTestId('convert-to-subgraph-button')
).toBeVisible()
})
@@ -88,7 +97,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -98,4 +107,152 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
)
expect(newCount).toBe(initialCount - 2)
})
test('bypass button toggles bypass on single node', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.vueNodes.waitForNodes()
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
expect(await nodeRef.isBypassed()).toBe(false)
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, 'KSampler')).toHaveClass(
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, 'KSampler')).not.toHaveClass(
BYPASS_CLASS
)
})
test('convert-to-subgraph button converts node to subgraph', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const convertButton = comfyPage.page.getByTestId(
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))
.toHaveLength(0)
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
.toHaveLength(1)
})
test('convert-to-subgraph button converts multiple nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const convertButton = comfyPage.page.getByTestId(
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
.toHaveLength(1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - 1)
})
test('frame nodes button creates group from multiple selected nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await frameButton.click({ force: true })
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.graph.groups.length)
)
.toBe(initialGroupCount + 1)
})
test('frame nodes button is not visible for single selection', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).not.toBeVisible()
})
test('execute button visible when output node selected', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('Save Image')
)[0]
await selectNodeWithPan(comfyPage, saveImageRef)
const executeButton = comfyPage.page.getByRole('button', {
name: /Execute to selected output nodes/i
})
await expect(executeButton).toBeVisible()
})
test('execute button not visible when non-output node selected', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const executeButton = comfyPage.page.getByRole('button', {
name: /Execute to selected output nodes/i
})
await expect(executeButton).not.toBeVisible()
})
})

View File

@@ -624,21 +624,30 @@ test.describe('Assets sidebar - pagination', () => {
await comfyPage.assets.clearMocks()
})
test('Initially loads a batch of assets with has_more pagination', async ({
test('initial load fetches first batch with offset 0', async ({
comfyPage
}) => {
// Create a large set of jobs to trigger pagination
const manyJobs = createMockJobs(30)
const manyJobs = createMockJobs(250)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
// Capture the first history fetch (terminal statuses only).
// Queue polling also hits /jobs but with status=in_progress,pending.
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
const status = url.searchParams.get('status') ?? ''
return status.includes('completed')
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Should load at least the first batch
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
const req = await firstRequest
const url = new URL(req.url())
expect(url.searchParams.get('offset')).toBe('0')
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
})
})
@@ -667,3 +676,83 @@ test.describe('Assets sidebar - settings menu', () => {
await expect(tab.gridViewOption).toBeVisible()
})
})
// ==========================================================================
// 11. Delete confirmation
// ==========================================================================
test.describe('Assets sidebar - delete confirmation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockDeleteHistory()
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-click delete shows confirmation dialog', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Delete this asset?')).toBeVisible()
await expect(
dialog.getByText('This asset will be permanently removed.')
).toBeVisible()
})
test('Confirming delete removes asset and shows success toast', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.delete.click()
await expect(dialog).not.toBeVisible()
await expect(tab.assetCards).toHaveCount(initialCount - 1, {
timeout: 5000
})
const successToast = comfyPage.page.locator('.p-toast-message-success')
await expect(successToast).toBeVisible({ timeout: 5000 })
})
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.reject.click()
await expect(dialog).not.toBeVisible()
await expect(tab.assetCards).toHaveCount(initialCount)
})
})

View File

@@ -0,0 +1,244 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
const MOCK_FOLDERS: Record<string, string[]> = {
checkpoints: [
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVision_v51.safetensors'
],
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
vae: ['sdxl_vae.safetensors']
}
// ==========================================================================
// 1. Tab open/close
// ==========================================================================
test.describe('Model library sidebar - tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.modelTree).toBeVisible()
await expect(tab.searchInput).toBeVisible()
})
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.refreshButton).toBeVisible()
await expect(tab.loadAllFoldersButton).toBeVisible()
})
})
// ==========================================================================
// 2. Folder display
// ==========================================================================
test.describe('Model library sidebar - folders', () => {
// Mocks are set up before setup(), so app.ts's loadModelFolders()
// call during initialization hits the mock and populates the store.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Displays model folders after opening tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
await expect(tab.getFolderByLabel('loras')).toBeVisible()
await expect(tab.getFolderByLabel('vae')).toBeVisible()
})
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Click the folder to expand it
await tab.getFolderByLabel('checkpoints').click()
// Models should appear as leaf nodes
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible({
timeout: 5000
})
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
})
test('Expanding a different folder shows its models', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('loras').click()
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible({
timeout: 5000
})
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
})
})
// ==========================================================================
// 3. Search
// ==========================================================================
test.describe('Model library sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Search filters models by filename', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.searchInput.fill('dreamshaper')
// Wait for debounce (300ms) + load + render
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
timeout: 5000
})
// Other models should not be visible
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
})
test('Clearing search restores folder view', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.searchInput.fill('dreamshaper')
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
timeout: 5000
})
// Clear the search
await tab.searchInput.fill('')
// Folders should be visible again (collapsed)
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible({
timeout: 5000
})
await expect(tab.getFolderByLabel('loras')).toBeVisible()
})
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.searchInput.fill('nonexistent_model_xyz')
// Wait for debounce, then verify no leaf nodes
await expect
.poll(async () => await tab.leafNodes.count(), { timeout: 5000 })
.toBe(0)
})
})
// ==========================================================================
// 4. Refresh and load all
// ==========================================================================
test.describe('Model library sidebar - refresh', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Refresh button reloads folder list', async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles({
checkpoints: ['model_a.safetensors']
})
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
// Update mock to include a new folder
await comfyPage.modelLibrary.clearMocks()
await comfyPage.modelLibrary.mockFoldersWithFiles({
checkpoints: ['model_a.safetensors'],
loras: ['lora_b.safetensors']
})
// Wait for the refresh request to complete
const refreshRequest = comfyPage.page.waitForRequest(
(req) => req.url().endsWith('/experiment/models'),
{ timeout: 5000 }
)
await tab.refreshButton.click()
await refreshRequest
await expect(tab.getFolderByLabel('loras')).toBeVisible({ timeout: 5000 })
})
test('Load all folders button triggers loading all model data', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Wait for a per-folder model files request triggered by load all
const folderRequest = comfyPage.page.waitForRequest(
(req) =>
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
req.method() === 'GET',
{ timeout: 5000 }
)
await tab.loadAllFoldersButton.click()
await folderRequest
})
})
// ==========================================================================
// 5. Empty state
// ==========================================================================
test.describe('Model library sidebar - empty state', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Shows empty tree when no model folders exist', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockFoldersWithFiles({})
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.modelTree).toBeVisible()
expect(await tab.folderNodes.count()).toBe(0)
expect(await tab.leafNodes.count()).toBe(0)
})
})

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Node library sidebar V2', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
})
test('Can switch between tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await tab.blueprintsTab.click()
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
await tab.allTab.click()
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
})
test('All tab displays node tree with folders', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.getFolder('sampling')).toBeVisible()
})
test('Can expand folder and see nodes in All tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.expandFolder('sampling')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
})
test('Search filters nodes in All tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
await tab.searchInput.fill('KSampler')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
timeout: 5000
})
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
})
test('Drag node to canvas adds it', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.expandFolder('sampling')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const canvasBoundingBox = await comfyPage.page
.locator('#graph-canvas')
.boundingBox()
expect(canvasBoundingBox).not.toBeNull()
const targetPosition = {
x: canvasBoundingBox!.x + canvasBoundingBox!.width / 2,
y: canvasBoundingBox!.y + canvasBoundingBox!.height / 2
}
const nodeLocator = tab.getNode('KSampler (Advanced)')
await nodeLocator.dragTo(comfyPage.page.locator('#graph-canvas'), {
targetPosition
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 5000 })
.toBe(initialCount + 1)
})
test('Right-click node shows context menu with bookmark option', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.expandFolder('sampling')
const node = tab.getNode('KSampler (Advanced)')
await expect(node).toBeVisible()
await node.click({ button: 'right' })
const contextMenu = comfyPage.page.getByRole('menuitem', {
name: /Bookmark Node/
})
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Search clear restores folder view', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await expect(tab.getFolder('sampling')).toBeVisible()
await tab.searchInput.fill('KSampler')
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
timeout: 5000
})
await tab.searchInput.clear()
await tab.searchInput.press('Enter')
await expect(tab.getFolder('sampling')).toBeVisible({ timeout: 5000 })
})
test('Sort dropdown shows sorting options', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.sortButton.click()
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
const options = comfyPage.page.getByRole('menuitemradio')
await expect(options.first()).toBeVisible({ timeout: 3000 })
await expect
.poll(() => options.count(), { timeout: 3000 })
.toBeGreaterThanOrEqual(2)
})
})

View File

@@ -0,0 +1,65 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/** Locate a workflow label in whatever panel is visible (browse or search). */
function findWorkflow(page: Page, name: string) {
return page
.getByTestId(TestIds.sidebar.workflows)
.locator('.node-label', { hasText: name })
}
test.describe('Workflow sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({
'alpha-workflow.json': 'default.json',
'beta-workflow.json': 'default.json'
})
})
test('Search filters saved workflows by name', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
})
test('Clearing search restores all workflows', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await searchInput.fill('')
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible()
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
})
test('Search with no matches shows empty results', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('nonexistent_xyz')
await expect(
findWorkflow(comfyPage.page, 'alpha-workflow')
).not.toBeVisible()
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
})
})

View File

@@ -0,0 +1,148 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
const panel = comfyPage.menu.propertiesPanel.root
if (!(await panel.isVisible())) {
await comfyPage.actionbar.propertiesButton.click()
}
await expect(panel).toBeVisible()
return panel
}
async function selectSubgraphAndOpenEditor(
comfyPage: ComfyPage,
nodeTitle: string
) {
const subgraphNodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
expect(subgraphNodes.length).toBeGreaterThan(0)
await subgraphNodes[0].click('title')
await ensurePropertiesPanel(comfyPage)
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
await expect(editorToggle).toBeVisible()
await editorToggle.click()
const shownSection = comfyPage.page.getByTestId(
TestIds.subgraphEditor.shownSection
)
await expect(shownSection).toBeVisible()
return shownSection
}
async function collectWidgetLabels(shownSection: Locator) {
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
const texts = await labels.allTextContents()
return texts.map((t) => t.trim())
}
test.describe(
'Subgraph promoted widget panel',
{ tag: ['@node', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('SubgraphEditor (Settings panel)', () => {
test('linked promoted widgets have hide toggle disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const toggleButtons = shownSection.getByTestId(
TestIds.subgraphEditor.widgetToggle
)
await expect(toggleButtons.first()).toBeVisible()
const count = await toggleButtons.count()
for (let i = 0; i < count; i++) {
await expect(toggleButtons.nth(i)).toBeDisabled()
}
})
test('linked promoted widgets show link icon instead of eye icon', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const linkIcons = shownSection.getByTestId(
TestIds.subgraphEditor.iconLink
)
await expect(linkIcons.first()).toBeVisible()
const eyeIcons = shownSection.getByTestId(
TestIds.subgraphEditor.iconEye
)
await expect(eyeIcons).toHaveCount(0)
})
test('widget labels display renamed values instead of raw names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Input Test Subgraph'
)
const allTexts = await collectWidgetLabels(shownSection)
expect(allTexts.length).toBeGreaterThan(0)
// The fixture has a widget with name="text" but
// label="renamed_from_sidepanel". The panel should show the
// renamed label, not the raw widget name.
expect(allTexts).toContain('renamed_from_sidepanel')
expect(allTexts).not.toContain('text')
})
})
test.describe('Parameters tab (WidgetActions menu)', () => {
test('linked promoted widget menu should not show Hide/Show input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
expect(subgraphNodes.length).toBeGreaterThan(0)
await subgraphNodes[0].click('title')
const panel = await ensurePropertiesPanel(comfyPage)
const moreButtons = panel.getByTestId(
TestIds.subgraphEditor.widgetActionsMenuButton
)
await expect(moreButtons.first()).toBeVisible()
await moreButtons.first().click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
await expect(menu.getByText('Hide input')).toHaveCount(0)
await expect(menu.getByText('Show input')).toHaveCount(0)
await expect(menu.getByText('Rename')).toBeVisible()
})
})
}
)

View File

@@ -116,9 +116,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const dialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
})
const dialog = comfyPage.templatesDialog.filterByHeading('Modèles')
await expect(dialog).toBeVisible()
// Validate that French-localized strings from the templates index are rendered
@@ -220,8 +218,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
const sortBySelect = comfyPage.templatesDialog.getCombobox(/Sort/)
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect

View File

@@ -0,0 +1,154 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Workflow tabs', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setup()
})
test('Default workflow tab is visible on load', async ({ comfyPage }) => {
const tabNames = await comfyPage.menu.topbar.getTabNames()
expect(tabNames.length).toBe(1)
expect(tabNames[0]).toContain('Unsaved Workflow')
})
test('Creating a new workflow adds a tab', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
expect(await topbar.getTabNames()).toHaveLength(1)
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
const tabNames = await topbar.getTabNames()
expect(tabNames[1]).toContain('Unsaved Workflow (2)')
})
test('Switching tabs changes active workflow', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
const activeNameBefore = await topbar.getActiveTabName()
expect(activeNameBefore).toContain('Unsaved Workflow (2)')
await topbar.getTab(0).click()
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
const activeAfter = await topbar.getActiveTabName()
expect(activeAfter).not.toContain('(2)')
})
test('Closing a tab removes it', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
const remaining = await topbar.getTabNames()
expect(remaining[0]).toContain('Unsaved Workflow')
})
test('Right-clicking a tab shows context menu', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.getTab(0).click({ button: 'right' })
// Reka UI ContextMenuContent gets data-state="open" when active
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
await expect(contextMenu).toBeVisible({ timeout: 5000 })
await expect(
contextMenu.getByRole('menuitem', { name: /Close Tab/i }).first()
).toBeVisible()
await expect(
contextMenu.getByRole('menuitem', { name: /Save/i }).first()
).toBeVisible()
})
test('Context menu Close Tab action removes the tab', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await topbar.getTab(1).click({ button: 'right' })
const contextMenu = comfyPage.page.locator(
'[role="menu"][data-state="open"]'
)
await expect(contextMenu).toBeVisible({ timeout: 5000 })
await contextMenu
.getByRole('menuitem', { name: /Close Tab/i })
.first()
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
})
test('Closing the last tab creates a new default workflow', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await topbar.closeWorkflowTab('Unsaved Workflow')
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
const tabNames = await topbar.getTabNames()
expect(tabNames[0]).toContain('Unsaved Workflow')
})
test('Modified workflow shows unsaved indicator', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
// Modify the graph via litegraph API to trigger unsaved state
await comfyPage.page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
// WorkflowTab renders "•" when the workflow has unsaved changes
const activeTab = topbar.getActiveTab()
await expect(activeTab.locator('text=•')).toBeVisible({ timeout: 5000 })
})
test('Multiple tabs can be created, switched, and closed', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
// Create 2 additional tabs (3 total)
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
// Switch to first tab
await topbar.getTab(0).click()
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
// Close the middle tab
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -5,9 +5,9 @@ import {
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
const BYPASS_CLASS = /before:bg-bypass\/60/
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
@@ -15,12 +15,10 @@ async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
await header.click()
await header.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await fixture.header.click()
await fixture.header.click({ button: 'right' })
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
@@ -35,17 +33,13 @@ async function openMultiNodeContextMenu(
await comfyPage.nextFrame()
for (const title of titles) {
const header = comfyPage.vueNodes
.getNodeByTitle(title)
.locator('.lg-node-header')
await header.click({ modifiers: ['ControlOrMeta'] })
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstHeader = comfyPage.vueNodes
.getNodeByTitle(titles[0])
.locator('.lg-node-header')
const box = await firstHeader.boundingBox()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
@@ -53,16 +47,15 @@ async function openMultiNodeContextMenu(
{ button: 'right' }
)
const menu = comfyPage.page.locator('.p-contextmenu')
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
@@ -82,9 +75,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.locator(
'.node-title-editor input[type="text"]'
)
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
@@ -135,16 +126,12 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Pin')
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await expect(fixture.pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
const header = fixture.header
const posBeforeDrag = await header.boundingBox()
if (!posBeforeDrag) throw new Error('Header not found')
await comfyPage.canvasOps.dragAndDrop(
@@ -158,7 +145,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(pinIndicator).not.toBeVisible()
await expect(fixture.pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
})
@@ -244,7 +231,9 @@ test.describe('Vue Node Context Menu', () => {
comfyPage
}) => {
// Capture the original image src from the node's preview
const imagePreview = comfyPage.page.locator('.image-preview img')
const imagePreview = comfyPage.vueNodes
.getNodeByTitle('Load Image')
.getByTestId(TestIds.node.mainImage)
const originalSrc = await imagePreview.getAttribute('src')
// Write a test image into the browser clipboard
@@ -347,8 +336,7 @@ test.describe('Vue Node Context Menu', () => {
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
// Open node library sidebar and search for the blueprint
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
await comfyPage.nextFrame()
await comfyPage.menu.nodeLibraryTab.tabButton.click()
const searchBox = comfyPage.page.getByRole('combobox', {
name: 'Search'
})
@@ -414,20 +402,16 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Pin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await expect(fixture.pinIndicator).toBeVisible()
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Unpin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).not.toBeVisible()
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await expect(fixture.pinIndicator).not.toBeVisible()
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -460,11 +460,8 @@ test.describe('Workflow Persistence', () => {
.getWorkflowTab('Unsaved Workflow')
.click({ button: 'middle' })
// Click "Save" in the dirty close dialog (scoped to dialog)
const dialog = comfyPage.page.getByRole('dialog')
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
// Click "Save" in the dirty close dialog
await comfyPage.confirmDialog.click('save')
// Fill in the filename dialog
const saveDialog = comfyPage.menu.topbar.getSaveDialog()

View File

@@ -196,6 +196,56 @@ body: JSON.stringify([mockRelease])
body: JSON.stringify([{ id: 1, project: 'comfyui', version: 'v0.3.44', ... }])
```
## When to Use `page.evaluate`
### Acceptable (use sparingly)
Reading internal state that has no UI representation (prefer locator
assertions whenever possible):
```typescript
// Reading graph/store values
const nodeCount = await page.evaluate(() => window.app!.graph!.nodes.length)
const linkSlot = await page.evaluate(() => window.app!.graph!.links.get(1)?.target_slot)
// Reading computed properties or store state
await page.evaluate(() => useWorkflowStore().activeWorkflow)
// Setting up test fixtures (registering extensions, mock error handlers)
await page.evaluate(() => {
window.app!.registerExtension({ name: 'TestExt', settings: [...] })
})
```
### Avoid
Performing actions that have a UI equivalent — use Playwright locators and user
interactions instead:
```typescript
// Bad: setting a widget value programmatically
await page.evaluate(() => { node.widgets![0].value = 512 })
// Good: click the widget and type the value
await widgetLocator.click()
await widgetLocator.fill('512')
// Bad: dispatching synthetic DOM events
await page.evaluate(() => { btn.dispatchEvent(new MouseEvent('click', ...)) })
// Good: use Playwright's click
await page.getByTestId('more-options-button').click()
// Bad: calling store actions that correspond to user interactions
await page.evaluate(() => { app.queuePrompt(0) })
// Good: click the Queue button
await page.getByRole('button', { name: 'Queue' }).click()
```
### Preferred
Use helper methods from `browser_tests/fixtures/helpers/` that wrap real user
interactions (e.g., `comfyPage.settings.setSetting`, `comfyPage.nodeOps`,
`comfyPage.workflow.loadWorkflow`).
## Running Tests
```bash

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.11",
"version": "1.43.13",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -73,6 +73,7 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tanstack/vue-virtual": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 534 B

50
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ catalogs:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@astrojs/sitemap':
specifier: ^3.7.1
version: 3.7.1
'@astrojs/vue':
specifier: ^5.0.0
version: 5.1.4
@@ -102,6 +105,9 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.2.0
version: 4.2.0
'@tanstack/vue-virtual':
specifier: ^3.13.12
version: 3.13.12
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -458,6 +464,9 @@ importers:
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 0.1.10
'@tanstack/vue-virtual':
specifier: 'catalog:'
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
'@tiptap/core':
specifier: 'catalog:'
version: 2.27.2(@tiptap/pm@2.27.2)
@@ -904,6 +913,9 @@ importers:
apps/website:
dependencies:
'@astrojs/sitemap':
specifier: 'catalog:'
version: 3.7.1
'@comfyorg/design-system':
specifier: workspace:*
version: link:../../packages/design-system
@@ -1089,6 +1101,9 @@ packages:
resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
'@astrojs/sitemap@3.7.1':
resolution: {integrity: sha512-IzQqdTeskaMX+QDZCzMuJIp8A8C1vgzMBp/NmHNnadepHYNHcxQdGLQZYfkbd2EbRXUfOS+UDIKx8sKg0oWVdw==}
'@astrojs/telemetry@3.3.0':
resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
@@ -4388,6 +4403,9 @@ packages:
'@types/react@19.1.9':
resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
@@ -5085,6 +5103,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
@@ -8692,6 +8713,11 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
sitemap@9.0.1:
resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==}
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
hasBin: true
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -8773,6 +8799,9 @@ packages:
prettier:
optional: true
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -10084,6 +10113,12 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/sitemap@3.7.1':
dependencies:
sitemap: 9.0.1
stream-replace-string: 2.0.0
zod: 4.3.6
'@astrojs/telemetry@3.3.0':
dependencies:
ci-info: 4.4.0
@@ -13436,6 +13471,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sax@1.2.7':
dependencies:
'@types/node': 25.0.3
'@types/semver@7.7.0': {}
'@types/stats.js@0.17.3': {}
@@ -14242,6 +14281,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
arg@5.0.2: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
@@ -18808,6 +18849,13 @@ snapshots:
sisteransi@1.0.5: {}
sitemap@9.0.1:
dependencies:
'@types/node': 24.10.4
'@types/sax': 1.2.7
arg: 5.0.2
sax: 1.4.4
slash@3.0.0: {}
slice-ansi@4.0.0:
@@ -18896,6 +18944,8 @@ snapshots:
- react-dom
- utf-8-validate
stream-replace-string@2.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3:

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
@@ -30,6 +31,7 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@tanstack/vue-virtual': ^3.13.12
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10

View File

@@ -51,7 +51,10 @@
}
"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<div
class="flex min-w-40 flex-col gap-2 p-2"
data-testid="more-menu-content"
>
<slot :close="hide" />
</div>
</Popover>

View File

@@ -37,13 +37,13 @@
</TreeRoot>
</ContextMenuTrigger>
<ContextMenuPortal v-if="showContextMenu">
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
<ContextMenuContent
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
@select="handleAddToFavorites"
@select="handleToggleBookmark"
>
<i
:class="
@@ -59,6 +59,14 @@
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
}}
</ContextMenuItem>
<ContextMenuItem
v-if="isCurrentNodeUserBlueprint"
class="text-destructive flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
@select="handleDeleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ $t('g.delete') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
@@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
@@ -98,7 +107,6 @@ const emit = defineEmits<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
event: MouseEvent
]
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
@@ -107,6 +115,7 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
provide(InjectKeyContextMenuNode, contextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const isCurrentNodeBookmarked = computed(() => {
const node = contextMenuNode.value
@@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => {
return nodeBookmarkStore.isBookmarked(node.data)
})
function handleAddToFavorites() {
if (contextMenuNode.value) {
emit('addToFavorites', contextMenuNode.value)
const isCurrentNodeUserBlueprint = computed(() =>
subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name)
)
function handleToggleBookmark() {
const node = contextMenuNode.value
if (node?.data) {
nodeBookmarkStore.toggleBookmark(node.data)
}
}
function handleDeleteBlueprint() {
const name = contextMenuNode.value?.data?.name
if (name) {
void subgraphStore.deleteBlueprint(name)
}
}
</script>

View File

@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
messages: { en: { g: { delete: 'Delete' } } }
})
vi.mock('@/platform/settings/settingStore', () => ({
@@ -29,6 +29,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
})
}))
const mockDeleteBlueprint = vi.fn()
const mockIsUserBlueprint = vi.fn().mockReturnValue(false)
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
isUserBlueprint: mockIsUserBlueprint,
deleteBlueprint: mockDeleteBlueprint,
typePrefix: 'SubgraphBlueprint.'
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -175,8 +186,12 @@ describe('TreeExplorerV2Node', () => {
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
it('does not set contextMenuNode for folder items', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
it('clears contextMenuNode when right-clicking a folder', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
key: 'stale',
type: 'node',
label: 'Stale'
} as RenderedTreeExplorerNode)
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
@@ -194,6 +209,59 @@ describe('TreeExplorerV2Node', () => {
})
})
describe('blueprint actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows delete button for user blueprints', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
})
it('hides delete button for non-blueprint nodes', () => {
mockIsUserBlueprint.mockReturnValue(false)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'KSampler' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
})
it('always shows bookmark button', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
})
it('calls deleteBlueprint when delete button is clicked', async () => {
mockIsUserBlueprint.mockReturnValue(true)
const nodeName = 'SubgraphBlueprint.test'
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: nodeName }
})
})
await wrapper.find('[aria-label="Delete"]').trigger('click')
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
})
})
describe('rendering', () => {
it('renders node icon for node type', () => {
const { wrapper } = mountComponent({

View File

@@ -25,25 +25,30 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
<div class="flex shrink-0 items-center gap-0.5">
<button
v-if="isUserBlueprint"
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
</div>
<!-- Folder -->
@@ -53,6 +58,7 @@
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="clearContextMenuNode"
>
<i
v-if="item.hasChildren"
@@ -96,6 +102,7 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -107,6 +114,9 @@ defineOptions({
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
}>()
@@ -120,6 +130,7 @@ const emit = defineEmits<{
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const nodeDef = computed(() => item.value.data)
@@ -128,12 +139,22 @@ const isBookmarked = computed(() => {
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
const isUserBlueprint = computed(() =>
subgraphStore.isUserBlueprint(nodeDef.value?.name)
)
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
function deleteBlueprint() {
if (nodeDef.value) {
void subgraphStore.deleteBlueprint(nodeDef.value.name)
}
}
const {
previewRef,
showPreview,
@@ -166,6 +187,12 @@ function handleContextMenu() {
}
}
function clearContextMenuNode() {
if (contextMenuNode) {
contextMenuNode.value = null
}
}
function handleMouseEnter(e: MouseEvent) {
if (item.value.type !== 'node') return
baseHandleMouseEnter(e)

View File

@@ -7,6 +7,7 @@
<EditableText
:is-editing="showInput"
:model-value="editedTitle"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="onEdit"
/>
</div>

View File

@@ -20,7 +20,7 @@
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="min-h-0 flex-1">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"

View File

@@ -1,4 +1,3 @@
/* eslint-disable vue/one-component-per-file -- test stubs */
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
import { fireEvent, render, screen } from '@testing-library/vue'
@@ -6,21 +5,27 @@ import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import './testUtils/mockTanstackVirtualizer'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
})
const hoisted = vi.hoisted(() => ({
jobDetailsPopoverStub: {
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
}
}))
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: hoisted.jobDetailsPopoverStub
}))
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
@@ -65,71 +70,81 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
type TestPreviewOutput = {
url: string
isImage: boolean
isVideo: boolean
}
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
taskRef?: TestTaskRef
}
type TestJobGroup = Omit<JobGroup, 'items'> & {
items: TestJobListItem[]
}
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
): TestPreviewOutput => {
const url = `/api/view/${filename}`
return {
url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
})
const buildJob = (
overrides: Partial<TestJobListItem> = {}
): TestJobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
function renderJobAssetsList(
jobs: JobListItem[],
callbacks: {
onViewItem?: (item: JobListItem) => void
} = {}
) {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
function renderJobAssetsList({
jobs = [],
displayedJobGroups,
attrs,
onViewItem
}: {
jobs?: TestJobListItem[]
displayedJobGroups?: TestJobGroup[]
attrs?: Record<string, string>
onViewItem?: (item: JobListItem) => void
} = {}) {
const user = userEvent.setup()
const result = render(JobAssetsList, {
props: {
displayedJobGroups,
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
displayedJobGroups: (displayedJobGroups ?? [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]) as JobGroup[],
...(onViewItem && { onViewItem })
},
attrs,
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub,
AssetsListItem: AssetsListItemStub
}
}
@@ -168,10 +183,57 @@ afterEach(() => {
})
describe('JobAssetsList', () => {
it('renders grouped headers alongside job rows', () => {
const displayedJobGroups: TestJobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
}
]
const { container } = renderJobAssetsList({ displayedJobGroups })
expect(screen.getByText('Today')).toBeTruthy()
expect(screen.getByText('Yesterday')).toBeTruthy()
expect(container.querySelector('[data-job-id="job-1"]')).not.toBeNull()
expect(container.querySelector('[data-job-id="job-2"]')).not.toBeNull()
})
it('forwards parent attrs to the scroll container', () => {
renderJobAssetsList({
attrs: {
class: 'min-h-0 flex-1'
},
displayedJobGroups: [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
}
]
})
expect(screen.getByTestId('job-assets-list').className.split(' ')).toEqual(
expect.arrayContaining([
'min-h-0',
'flex-1',
'h-full',
'overflow-y-auto',
'pb-4'
])
)
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { user } = renderJobAssetsList([job], { onViewItem })
const { user } = renderJobAssetsList({ jobs: [job], onViewItem })
await user.click(screen.getByTestId('preview-trigger'))
@@ -181,7 +243,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -192,10 +254,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(
@@ -211,10 +273,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
@@ -225,10 +287,10 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -242,7 +304,7 @@ describe('JobAssetsList', () => {
taskRef: createTaskRef()
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList([job], { onViewItem })
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
@@ -256,7 +318,7 @@ describe('JobAssetsList', () => {
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -286,7 +348,7 @@ describe('JobAssetsList', () => {
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -319,7 +381,7 @@ describe('JobAssetsList', () => {
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -344,7 +406,7 @@ describe('JobAssetsList', () => {
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -370,7 +432,9 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -398,7 +462,9 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -429,7 +495,7 @@ describe('JobAssetsList', () => {
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container, rerender } = renderJobAssetsList([job])
const { container, rerender } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!

View File

@@ -1,79 +1,92 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<div
v-for="job in group.items"
:key="job.id"
:data-job-id="job.id"
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<div
ref="scrollContainer"
v-bind="$attrs"
data-testid="job-assets-list"
class="h-full overflow-y-auto pb-4"
@scroll="onListScroll"
>
<div :style="virtualWrapperStyle">
<template v-for="{ row, virtualItem } in virtualRows" :key="row.key">
<div
v-if="row.type === 'header'"
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
:style="getVirtualRowStyle(virtualItem)"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
{{ row.label }}
</div>
<div
v-else-if="row.type === 'job'"
class="box-border px-3"
:style="getVirtualRowStyle(virtualItem)"
>
<div
:data-job-id="row.job.id"
class="h-12"
@mouseenter="onJobEnter(row.job, $event)"
@mouseleave="onJobLeave(row.job.id)"
>
<AssetsListItem
:class="
cn(
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
row.job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(row.job)"
:is-video-preview="isVideoPreviewJob(row.job)"
:preview-alt="row.job.title"
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
:icon-class="getJobIconClass(row.job)"
:primary-text="row.job.title"
:secondary-text="row.job.meta"
:progress-total-percent="row.job.progressTotalPercent"
:progress-current-percent="row.job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
@dblclick.stop="emitViewItem(row.job)"
@preview-click="emitViewItem(row.job)"
@click.stop
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<template v-if="hoveredJobId === row.job.id" #actions>
<Button
v-if="isCancelable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(row.job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(row.job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', row.job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
</div>
</div>
@@ -97,8 +110,11 @@
</template>
<script setup lang="ts">
import type { VirtualItem } from '@tanstack/vue-virtual'
import type { CSSProperties } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
@@ -110,6 +126,17 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import { buildVirtualJobRows } from './buildVirtualJobRows'
import type { VirtualJobRow } from './buildVirtualJobRows'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
const JOB_ROW_HEIGHT = 48
defineOptions({
inheritAttrs: false
})
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
@@ -120,9 +147,43 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const scrollContainer = ref<HTMLElement | null>(null)
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const virtualizer = useVirtualizer({
get count(): number {
return flatRows.value.length
},
getItemKey(index: number) {
return flatRows.value[index]?.key ?? index
},
estimateSize(index: number) {
const row = flatRows.value[index]
return row ? getRowHeight(row, index, flatRows.value) : JOB_ROW_HEIGHT
},
getScrollElement() {
return scrollContainer.value
},
overscan: 12
})
const virtualRows = computed(() => {
const rows = flatRows.value
return virtualizer.value
.getVirtualItems()
.flatMap((virtualItem: VirtualItem) => {
const row = rows[virtualItem.index]
return row ? [{ row, virtualItem }] : []
})
})
const virtualWrapperStyle = computed<CSSProperties>(() => ({
position: 'relative',
width: '100%',
...(flatRows.value.length > 0 && {
height: `${virtualizer.value.getTotalSize()}px`
})
}))
const {
activeDetails,
clearHoverTimers,
@@ -135,6 +196,37 @@ const {
onReset: clearPopoverAnchor
})
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
return {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
overflowAnchor: 'none'
}
}
function getRowHeight(
row: VirtualJobRow,
index: number,
rows: VirtualJobRow[]
): number {
if (row.type === 'header') {
return HEADER_ROW_HEIGHT
}
return (
JOB_ROW_HEIGHT + (rows[index + 1]?.type === 'header' ? GROUP_ROW_GAP : 0)
)
}
function onListScroll() {
hoveredJobId.value = null
resetActiveDetails()
}
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { buildVirtualJobRows } from './buildVirtualJobRows'
function buildJob(id: string): JobListItem {
return {
id,
title: `Job ${id}`,
meta: 'meta',
state: 'completed'
}
}
describe('buildVirtualJobRows', () => {
it('flattens grouped jobs into headers and rows in display order', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1'), buildJob('job-2')]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob('job-3')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today'
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0]
},
{
key: 'job-job-2',
type: 'job',
job: displayedJobGroups[0].items[1]
},
{
key: 'header-yesterday',
type: 'header',
label: 'Yesterday'
},
{
key: 'job-job-3',
type: 'job',
job: displayedJobGroups[1].items[0]
}
])
})
it('keeps a single group flattened without extra row metadata', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today'
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0]
}
])
})
})

View File

@@ -0,0 +1,37 @@
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
export type VirtualJobRow =
| {
key: string
type: 'header'
label: string
}
| {
key: string
type: 'job'
job: JobListItem
}
export function buildVirtualJobRows(
displayedJobGroups: JobGroup[]
): VirtualJobRow[] {
const rows: VirtualJobRow[] = []
displayedJobGroups.forEach((group) => {
rows.push({
key: `header-${group.key}`,
type: 'header',
label: group.label
})
group.items.forEach((job) => {
rows.push({
key: `job-${job.id}`,
type: 'job',
job
})
})
})
return rows
}

View File

@@ -0,0 +1,33 @@
import { vi } from 'vitest'
vi.mock('@tanstack/vue-virtual', async () => {
const { computed } = await import('vue')
return {
useVirtualizer: (options: {
count: number
estimateSize: (index: number) => number
getItemKey?: (index: number) => number | string
}) =>
computed(() => {
let start = 0
const items = Array.from({ length: options.count }, (_, index) => {
const size = options.estimateSize(index)
const item = {
key: options.getItemKey?.(index) ?? index,
index,
start,
size
}
start += size
return item
})
return {
getVirtualItems: () => items,
getTotalSize: () => start
}
})
}
})

View File

@@ -303,6 +303,7 @@ function handleTitleCancel() {
v-if="isSingleSubgraphNode"
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(

View File

@@ -15,6 +15,7 @@
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
aria-live="polite"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
@@ -168,7 +169,15 @@
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-else-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
</TransitionGroup>
@@ -225,6 +234,7 @@ import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud } from '@/platform/distribution/types'
import {
downloadModel,
@@ -261,7 +271,8 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model'
'missing_model',
'missing_media'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
@@ -283,6 +294,7 @@ const {
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -393,7 +405,7 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}

View File

@@ -25,3 +25,4 @@ export type ErrorGroup =
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }
| { type: 'missing_media'; title: string; priority: number }

View File

@@ -20,7 +20,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
return commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {

View File

@@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -29,7 +30,9 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -239,6 +242,7 @@ export function useErrorGroups(
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -635,6 +639,27 @@ export function useErrorGroups(
]
}
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
return groupCandidatesByMediaType(candidates)
})
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -645,6 +670,7 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...toSortedGroups(groupsMap)
]
})
@@ -663,6 +689,7 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...executionGroups
]
})
@@ -699,6 +726,7 @@ export function useErrorGroups(
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
}
}

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import { computed, reactive, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
@@ -28,66 +28,73 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
)
})
let cancelled = false
onUnmounted(() => {
cancelled = true
})
watch(
() => toValue(cardSource),
async (card, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
onMounted(async () => {
const card = toValue(cardSource)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
for (const key of Object.keys(enrichedDetails)) {
delete enrichedDetails[key as unknown as number]
}
if (runtimeErrors.length === 0) return
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(() => systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})
},
{ immediate: true }
)
return { displayedDetailsMap }
}

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -79,7 +78,6 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
@@ -88,7 +86,7 @@ function isWidgetShownOnParents(
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {

View File

@@ -14,10 +14,7 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -132,7 +129,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
})
)
})

View File

@@ -98,7 +98,8 @@ describe('WidgetActions', () => {
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
size: [200, 100],
isSubgraphNode: () => false
})
}
@@ -225,7 +226,8 @@ describe('WidgetActions', () => {
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
})
const widget = {
name: 'text',

View File

@@ -9,7 +9,7 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getSourceNodeId,
isLinkedPromotion,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -47,6 +47,11 @@ const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
isShownOnParents && hasParents.value ? parents[0] : node
)
@@ -76,8 +81,6 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const disambiguatingSourceNodeId = getSourceNodeId(widget)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
@@ -85,7 +88,7 @@ function handleHideInput() {
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
@@ -114,6 +117,7 @@ function handleResetToDefault() {
<template>
<MoreButton
is-vertical
data-testid="widget-actions-menu-button"
class="bg-transparent text-muted-foreground transition-all hover:bg-secondary-background-hover hover:text-base-foreground active:scale-95"
>
<template #default="{ close }">
@@ -133,7 +137,7 @@ function handleResetToDefault() {
</Button>
<Button
v-if="hasParents"
v-if="canToggleVisibility"
variant="textonly"
size="unset"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"

View File

@@ -11,6 +11,7 @@ import {
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
@@ -88,14 +89,13 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
refreshPromotedWidgetRendering()
}
@@ -123,7 +123,9 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
)
})
@@ -162,6 +164,18 @@ function refreshPromotedWidgetRendering() {
canvasStore.canvas?.setDirty(true, true)
}
function isItemLinked([node, widget]: WidgetItem): boolean {
return (
node.id === -1 ||
(!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(node.id),
getWidgetName(widget)
))
)
}
function toKey(item: WidgetItem) {
const sid = getSourceNodeId(item[1])
return sid
@@ -187,8 +201,14 @@ function showAll() {
}
}
function hideAll() {
const node = activeNode.value
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
if (
node &&
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
)
continue
demote(item)
}
}
@@ -223,6 +243,7 @@ onMounted(() => {
<div
v-if="filteredActive.length"
data-testid="subgraph-editor-shown-section"
class="flex flex-col border-b border-interface-stroke"
>
<div
@@ -244,8 +265,8 @@ onMounted(() => {
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.name"
:is-physical="node.id === -1"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
/>
@@ -254,6 +275,7 @@ onMounted(() => {
<div
v-if="filteredCandidates.length"
data-testid="subgraph-editor-hidden-section"
class="flex flex-col border-b border-interface-stroke"
>
<div

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ClassValue } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
nodeTitle,
widgetName,
isDraggable = false,
isPhysical = false,
class: className
} = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
@@ -14,13 +22,13 @@ defineEmits<{
(e: 'toggleVisibility'): void
}>()
function getIcon() {
return props.isPhysical
const icon = computed(() =>
isPhysical
? 'icon-[lucide--link]'
: props.isDraggable
: isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
}
)
</script>
<template>
@@ -29,8 +37,8 @@ function getIcon() {
cn(
'flex items-center gap-1 rounded-sm px-2 py-1 break-all',
'bg-node-component-surface',
props.isDraggable && 'ring-accent-background hover:ring-1',
props.class
isDraggable && 'ring-accent-background hover:ring-1',
className
)
"
>
@@ -38,15 +46,18 @@ function getIcon() {
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
{{ widgetName }}
</div>
</div>
<Button
variant="muted-textonly"
size="sm"
data-testid="subgraph-widget-toggle"
:disabled="isPhysical"
@click.stop="$emit('toggleVisibility')"
>
<i :class="getIcon()" />
<i :class="icon" :data-testid="isPhysical ? 'icon-link' : 'icon-eye'" />
</Button>
<div
v-if="isDraggable"

View File

@@ -1,163 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
})
vi.mock('@/composables/queue/useJobList', async () => {
const { ref } = await import('vue')
const jobHistoryItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
url: '/api/view/job-1.png'
}
}
}
return {
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: ref([]),
groupedJobItems: ref([
{
key: 'group-1',
label: 'Group 1',
items: [jobHistoryItem]
}
])
})
}
})
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
cancelJob: vi.fn()
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: vi.fn()
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
fn: T
) => fn
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: vi.fn()
})
}))
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
function mountComponent() {
return mount(JobHistorySidebarTab, {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: SidebarTabTemplateStub,
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
const wrapper = mountComponent()
const jobRow = wrapper.find('[data-job-id="job-1"]')
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
})
})
})

View File

@@ -46,22 +46,25 @@
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<div class="flex h-full min-h-0 flex-col">
<JobAssetsList
class="min-h-0 flex-1"
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</div>
</template>
</SidebarTabTemplate>
</template>

View File

@@ -12,7 +12,6 @@
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
<div v-else class="px-6 py-2 text-xs text-muted-background">
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
@@ -31,7 +30,6 @@
:root="section.root"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</div>
</div>
@@ -71,12 +69,4 @@ const hasFavorites = computed(
const favoritesRoot = computed(() =>
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
)
function handleAddToFavorites(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) {
if (node.data) {
nodeBookmarkStore.toggleBookmark(node.data)
}
}
</script>

View File

@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
@@ -32,7 +33,8 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
missingModelExecIds: Set<string>,
missingMediaExecIds: Set<string> = new Set()
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
@@ -64,6 +66,11 @@ function reconcileNodeErrorFlags(
if (node) flaggedNodes.add(node)
}
for (const execId of missingMediaExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
@@ -78,7 +85,8 @@ function reconcileNodeErrorFlags(
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
@@ -89,12 +97,13 @@ export function useNodeErrorFlagSync(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// Legacy (LGraphNode) only: suppress missing-model/media error flags
// when the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
@@ -102,6 +111,9 @@ export function useNodeErrorFlagSync(
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),
showErrorsTab.value
? missingMediaStore.missingMediaAncestorExecutionIds
: new Set()
)
},

View File

@@ -20,6 +20,7 @@ import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -334,3 +335,84 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
}
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
})
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
})
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})

View File

@@ -27,6 +27,27 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
/**
* Returns true if the given promotion entry corresponds to a linked promotion
* on the subgraph node. Linked promotions are driven by subgraph input
* connections and cannot be independently hidden or shown.
*/
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
const w = input._widget
return (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
})
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
@@ -39,7 +60,9 @@ function toPromotionSource(
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
}
}

View File

@@ -1,68 +1,93 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { createBounds, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
/**
* Draws a dashed border around selected items that maintains constant pixel size
* regardless of zoom level, similar to the DOM selection overlay.
*/
function getSelectionBounds(canvas: LGraphCanvas): ReadOnlyRect | null {
const selectedItems = canvas.selectedItems
if (selectedItems.size <= 1) return null
if (!LiteGraph.vueNodesMode) return createBounds(selectedItems, 10)
// In Vue mode, use layoutStore.collapsedSize for collapsed nodes
// to get accurate dimensions instead of litegraph's fallback values.
const padding = 10
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const collapsedSize =
id && isCollapsed ? layoutStore.getNodeCollapsedSize(id) : undefined
if (collapsedSize) {
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + collapsedSize.width)
maxY = Math.max(maxY, rect[1] + collapsedSize.height)
} else {
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])
}
}
if (!Number.isFinite(minX)) return null
return [
minX - padding,
minY - padding,
maxX - minX + 2 * padding,
maxY - minY + 2 * padding
]
}
function drawSelectionBorder(
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas
) {
const selectedItems = canvas.selectedItems
// Only draw if multiple items selected
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, 10)
const bounds = getSelectionBounds(canvas)
if (!bounds) return
const [x, y, width, height] = bounds
// Save context state
ctx.save()
// Set up dashed line style that doesn't scale with zoom
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
const borderWidth = 2 / canvas.ds.scale
ctx.lineWidth = borderWidth
ctx.strokeStyle =
getComputedStyle(document.documentElement)
.getPropertyValue('--border-color')
.trim() || '#ffffff66'
// Create dash pattern that maintains visual size
const dashSize = 5 / canvas.ds.scale
ctx.setLineDash([dashSize, dashSize])
// Draw the border using the bounds directly
ctx.beginPath()
ctx.roundRect(x, y, width, height, 8 / canvas.ds.scale)
ctx.stroke()
// Restore context
ctx.restore()
}
/**
* Extension that adds a dashed selection border for multiple selected nodes
*/
const ext = {
name: 'Comfy.SelectionBorder',
async init() {
// Hook into the canvas drawing
const originalDrawForeground = app.canvas.onDrawForeground
app.canvas.onDrawForeground = function (
ctx: CanvasRenderingContext2D,
visibleArea: Rectangle
) {
// Call original if it exists
originalDrawForeground?.call(this, ctx, visibleArea)
// Draw our selection border
drawSelectionBorder(ctx, app.canvas)
}
}

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