Compare commits

...

92 Commits

Author SHA1 Message Date
bymyself
ed7ceab571 refactor: rename firebaseAuthStore to authStore with shared test fixtures
- Rename src/stores/firebaseAuthStore.ts → src/stores/authStore.ts
- Rename src/composables/auth/useFirebaseAuthActions.ts → src/composables/auth/useAuthActions.ts
- Rename exports: useFirebaseAuthStore → useAuthStore, FirebaseAuthStoreError → AuthStoreError
- Update store ID from 'firebaseAuth' to 'auth'
- Create shared mock factory at src/stores/__tests__/authStoreMock.ts
- Update 27 production import sites and 16 test files

Fixes #8219
2026-03-24 16:25:39 -07:00
Christian Byrne
e40995fb6c fix: restore Firebase getAdditionalUserInfo for sign-up telemetry OR logic (#10453)
## Summary

Restores `getAdditionalUserInfo` from Firebase Auth so sign-up telemetry
fires when *either* Firebase or the UI context identifies a new user,
fixing a regression from #10388.

## Changes

- **What**: In `loginWithGoogle` and `loginWithGithub`, call
`getAdditionalUserInfo(result)` and OR it with the UI-provided
`options?.isNewUser` flag: `is_new_user: options?.isNewUser ||
additionalUserInfo?.isNewUser || false`. Added 8 parameterized unit
tests covering the OR truth table (Firebase true, UI true, both false,
null result).

## Review Focus

The OR semantics: if either source says new user, we send `sign_up`
telemetry. Previously only the UI flag was checked, which missed cases
where the user lands directly on the OAuth provider without going
through the sign-up view.

## Testing

Unit tests cover all branches of the OR logic. An e2e test is not
feasible here because it would require completing a real OAuth flow with
Google/GitHub (interactive popup, valid credentials, CAPTCHA) and
intercepting the resulting `getAdditionalUserInfo` response from
Firebase — none of which can be reliably automated in a headless
Playwright environment without a live Firebase project seeded with
disposable accounts.

Fixes #10447
2026-03-24 12:39:48 -07:00
Christian Byrne
908a3ea418 fix: resolve subgraph node slot link misalignment during workflow load (#9121)
## Summary

Fix subgraph node slot connector links appearing misaligned after
workflow load, caused by a transform desync between LiteGraph's internal
canvas transform and the Vue TransformPane's CSS transform.

## Changes

- **What**: Changed `syncNodeSlotLayoutsFromDOM` to use DOM-relative
measurement (slot position relative to its parent `[data-node-id]`
element) instead of absolute canvas-space conversion via
`clientPosToCanvasPos`. This makes the slot offset calculation
independent of the global canvas transform, eliminating the frame-lag
desync that occurred when `fitView()` updated `lgCanvas.ds` before the
Vue CSS transform caught up.
- **Cleanup**: Removed the unreachable fallback path that still used
`clientPosToCanvasPos` when the parent node element wasn't found (every
slot element is necessarily a child of a `[data-node-id]` element — if
`closest()` fails the element is detached and measuring is meaningless).
This also removed the `conv` parameter from `syncNodeSlotLayoutsFromDOM`
and `flushScheduledSlotLayoutSync`, and the
`useSharedCanvasPositionConversion` import.
- **Test**: Added a Playwright browser test that loads a subgraph
workflow with `workflowRendererVersion: "LG"` (triggering the 1.2x scale
in `ensureCorrectLayoutScale`) as a template (triggering `fitView`), and
verifies all slot connector positions are within bounds of their parent
node element.

## Review Focus

- The core change is in `useSlotElementTracking.ts` — the new
measurement approach uses `getBoundingClientRect()` on both the slot and
its parent node element, then divides by `currentScale` to get
canvas-space offsets. This is simpler and more robust than the previous
approach.
- SubgraphNodes were disproportionately affected because they are
relatively static and don't often trigger `ResizeObserver`-based
re-syncs that would eventually correct stale offsets.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9121-fix-resolve-subgraph-node-slot-link-misalignment-during-workflow-load-3106d73d365081eca413c84f2e0571d6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:28:12 -07:00
AustinMroz
0ae4b78cbc Use native context menu for focused textareas (#10454)
The custom context menu provided by the frontend exposes widget specific
options. In order to support renaming, promotion, and favoriting, there
needs to be a way to access this context menu when targeting a textarea.
However, always displaying this custom context menu will cause the user
to lose access to browser specific functionality like spell checking,
translation, and the ability to copy paste text.

This PR updates the behaviour so that the native browser context menu
will display when the text area already has focus. Our custom frontend
context menu will continue to display when it does not.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10454-Use-native-context-menu-for-focused-textareas-32d6d73d365081909673d81d6a6ba054)
by [Unito](https://www.unito.io)
2026-03-24 12:25:35 -07:00
Christian Byrne
5f14276159 [feat] Improve group title layout (#9839)
Rebased and adopted from #5774 by @felixturner.

## Changes
- Remove unused font-size properties (`NODE_TEXT_SIZE`,
`NODE_SUBTEXT_SIZE`, `DEFAULT_GROUP_FONT`) from theme palettes and color
palette schema
- Replace `DEFAULT_GROUP_FONT`/`DEFAULT_GROUP_FONT_SIZE` with a single
`GROUP_TEXT_SIZE = 20` constant (reduced from 24px)
- Use `NODE_TITLE_HEIGHT` for group header height instead of `font_size
* 1.4`
- Vertically center group title text using `textBaseline = 'middle'`
- Use `GROUP_TEXT_SIZE` directly in TitleEditor instead of per-group
`font_size`
- Remove `font_size` from group serialization (no longer per-group
configurable)

## Original PR
Closes #5774

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9839-feat-Improve-group-title-layout-3216d73d36508112a0edc2a370af20ba)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Felix Turner <felixturner@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:25:18 -07:00
Christian Byrne
df81c66725 fix: prevent blueprint cache corruption on repeated placement (#9897)
Add `structuredClone()` in `getBlueprint()` so `_deserializeItems()`
mutations (node IDs, subgraph UUIDs) don't corrupt cached data.

Includes regression test verifying cache immutability.

Fixes COM-16026

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9897-fix-prevent-blueprint-cache-corruption-on-repeated-placement-3226d73d3650815c8594fa8d266e680f)
by [Unito](https://www.unito.io)
2026-03-24 12:24:41 -07:00
Christian Byrne
c5ad6cf1aa fix: migrate V1 tab state pointers during V1→V2 draft migration (#10007)
## Problem

When upgrading from V1 to V2 draft persistence, users' open workflow
tabs were lost. V1 stored tab state (open paths + active index) in
localStorage via `setStorageValue` fallback, but the V1→V2 migration
only migrated draft payloads — not these tab state pointers.

This meant that after upgrading, all previously open tabs disappeared
and users had to manually reopen their workflows.

## Solution

Add `migrateV1TabState()` to the V1→V2 migration path. After draft
payloads are migrated, the function reads the V1 localStorage keys
(`Comfy.OpenWorkflowsPaths` and `Comfy.ActiveWorkflowIndex`) and writes
them to V2's sessionStorage format via `writeOpenPaths()`.

The `clientId` is threaded from `useWorkflowPersistenceV2` (which has
access to `api.clientId`) through to `migrateV1toV2()`.

## Changes

- **`migrateV1toV2.ts`**: Added `migrateV1TabState()` + V1 key constants
for tab state
- **`useWorkflowPersistenceV2.ts`**: Pass `api.clientId` to migration
call
- **`migrateV1toV2.test.ts`**: Two new tests proving tab state migration
works

## Testing

TDD approach — RED commit shows the test failing, GREEN commit shows it
passing.

All 123 persistence tests pass.

- Fixes #9974

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10007-fix-migrate-V1-tab-state-pointers-during-V1-V2-draft-migration-3256d73d36508103b619e521c1b603f5)
by [Unito](https://www.unito.io)
2026-03-24 12:23:59 -07:00
Terry Jia
fe1fae4de1 fix: disable preload error toast triggered by third-party plugin failures (#10445)
## Summary
The preload error toast fires whenever any custom node extension fails
to load via dynamic `import()`. In practice, this is almost always
caused by third-party plugin bugs rather than ComfyUI core issues.
Common triggers include:

- Bare module specifiers (e.g., `import from "vue"`) that the browser
cannot resolve without an import map
- Incorrect relative paths to `scripts/app.js` due to nested web
directory structures
  - Missing dependencies on other extensions (e.g., `clipspace.js`)

Since many users have multiple custom nodes installed, the toast
frequently appears on startup — sometimes multiple times — with a
generic message that offers no actionable guidance. This creates
unnecessary alarm and support burden.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10445-fix-disable-preload-error-toast-triggered-by-third-party-plugin-failures-32d6d73d365081f281efcd6fe90642a5)
by [Unito](https://www.unito.io)
2026-03-24 13:34:34 -04:00
Johnpaul Chiwetelu
38aad02fb9 test: add e2e tests for all default keybindings (#10440)
## Summary
- Add 21 Playwright E2E tests covering all previously untested default
keybindings
- Add `isReadOnly()` and `getOffset()` helpers to `CanvasHelper`

### Tests added
| Group | Keybindings | Count |
|-------|------------|-------|
| Sidebar toggles | `w`, `n`, `m`, `a` | 4 |
| Canvas controls | `Alt+=`, `Alt+-`, `.`, `h`, `v` | 5 |
| Node state | `Alt+c`, `Ctrl+m` | 2 |
| Mode/panel toggles | `Alt+m`, `Alt+Shift+m`, `` Ctrl+` `` | 3 |
| Queue/execution | `Ctrl+Enter`, `Ctrl+Shift+Enter`, `Ctrl+Alt+Enter` |
3 |
| File operations | `Ctrl+s`, `Ctrl+o` | 2 |
| Graph operations | `Ctrl+Shift+e`, `r` | 2 |

## Test plan
- [x] `pnpm exec playwright test defaultKeybindings.spec.ts` — 21/21
pass
- [x] `pnpm typecheck:browser` — clean
- [x] `pnpm lint` — clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10440-test-add-e2e-tests-for-all-default-keybindings-32d6d73d36508124a510d4742f9fbdbb)
by [Unito](https://www.unito.io)
2026-03-24 16:12:59 +01:00
pythongosssss
7c4234cf93 feat: App mode - add execution status messages (#10369)
## Summary

Adds custom status messages that are shown under the previews in order
to provide additional progress feedback to the user

Nodes matching the words:

Save, Preview -> Saving
Load, Loader -> Loading
Encode -> Encoding
Decode -> Decoding
Compile, Conditioning, Merge, -> Processing
Upscale, Resize -> Resizing
ToVideo -> Generating video

Specific nodes:
KSampler, KSamplerAdvanced, SamplerCustom, SamplerCustomAdvanced ->
Generating
Video Slice, GetVideoComponents, CreateVideo -> Processing video
TrainLoraNode -> Training


## Changes

- **What**: 
- add specific node lookups for non-easily matchable patterns
- add regex based matching for common patterns
- show on both latent preview & skeleton preview 
- allow app mode workflow authors to override status with custom
property `Execution Message` (no UI for doing this)

## Review Focus

This is purely pattern/lookup based, in future we could update the
backend node schema to allow nodes to define their own status key.

## Screenshots (if applicable)

<img width="757" height="461" alt="image"
src="https://github.com/user-attachments/assets/2b32cc54-c4e7-4aeb-912d-b39ac8428be7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10369-feat-App-mode-add-execution-status-messages-32a6d73d3650814e8ca2da5eb33f3b65)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-24 07:58:35 -07:00
jaeone94
4bfc730a0e fix: restore workflow tabs on browser restart (#10336)
## Problem

Since PR #8520 (`feat(persistence): fix QuotaExceededError and
cross-workspace draft leakage`), all workflow tabs are lost when the
browser is closed and reopened.

PR #8520 moved tab pointers (`ActivePath`, `OpenPaths`) from
`localStorage` to `sessionStorage` for per-tab isolation. However,
`sessionStorage` is cleared when the browser closes, so the open tab
list is lost on restart. The draft data itself survives in
`localStorage` — only the pointers to which tabs were open are lost.

Reported in
[Comfy-Org/ComfyUI#12984](https://github.com/Comfy-Org/ComfyUI/issues/12984).
Confirmed via binary search: v1.40.9 (last good) → v1.40.10 (first bad).

## Changes

Dual-write tab pointers to both storage layers:

- **sessionStorage** (scoped by `clientId`) — used for in-session
refresh, preserves per-tab isolation
- **localStorage** (scoped by `workspaceId`) — fallback for browser
restart when sessionStorage is empty

Also adds:
- `storageAvailable` guard on write functions for consistency with
`writeIndex`/`writePayload`
- `isValidPointer` validation on localStorage reads to reject stale or
malformed data

## Benefits

- Workflow tabs survive browser restart (restores V1 behavior)
- Per-tab isolation is preserved for in-session use (sessionStorage is
still preferred when available)

## Trade-offs

- On browser restart, the restored tabs come from whichever browser tab
wrote last to localStorage. If Tab A had workflows 1,2,3 and Tab B had
4,5 — the user gets whichever tab wrote most recently. This is the same
limitation V1 had with `Comfy.OpenWorkflowsPaths` in localStorage.
- Previously (post-#8520), opening a new browser tab would only restore
the single most recent draft. With this fix, a new tab restores the full
set of open tabs from the last session. This may be surprising for
multi-tab users who expect a clean slate in new tabs.

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 121 persistence tests pass
- [x] Manual: open multiple workflow tabs → close browser → reopen →
tabs restored
- [x] Manual: open two browser tabs with different workflows → refresh
each → correct tabs in each

Fixes Comfy-Org/ComfyUI#12984

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10336-fix-restore-workflow-tabs-on-browser-restart-3296d73d365081b7a7d3e91427d08d17)
by [Unito](https://www.unito.io)
<!-- QA_REPORT_SECTION -->
---
## 🔍 Automated QA Report

| | |
|---|---|
| **Status** |  Complete |
| **Report** |
[sno-qa-10336.comfy-qa.pages.dev](https://sno-qa-10336.comfy-qa.pages.dev/)
|
| **CI Run** | [View
workflow](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23373697656)
|

Before/after video recordings with **Behavior Changes** and **Timeline
Comparison** tables.
2026-03-24 17:30:36 +09:00
Yourz
001916edf6 refactor: clean up essentials node organization logic (#10433)
## Summary

Refactor essentials tab node organization to eliminate duplicated logic
and restrict essentials to core nodes only.

## Changes

- **What**: 
- Extract `resolveEssentialsCategory` to centralize category resolution
(was duplicated between filter and pathExtractor).
- Add `isCoreNode` guard so third-party nodes never appear in
essentials.
- Replace `indexOf`-based sorting with precomputed rank maps
(`ESSENTIALS_CATEGORY_RANK`, `ESSENTIALS_NODE_RANK`).

<img width="589" height="769" alt="image"
src="https://github.com/user-attachments/assets/66f41f35-aef5-4e12-97d5-0f33baf0ac45"
/>


## Review Focus

- The `isCoreNode` guard in `resolveEssentialsCategory` — ensures only
core nodes can appear in essentials even if a custom node sets
`essentials_category`.
- Rank map precomputation vs previous `indexOf` — functionally
equivalent but O(1) lookup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10433-refactor-clean-up-essentials-node-organization-logic-32d6d73d36508193a4d1f7f9c18fcef7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 16:22:09 +08:00
jaeone94
66daa6d645 refactor: error system cleanup — store separation, DDD fix, test improvements (#10302)
## Summary

Refactors the error system to improve separation of concerns, fix DDD
layer violations, and address code quality issues.

- Extract `missingNodesErrorStore` from `executionErrorStore`, removing
the delegation pattern that coupled missing-node logic into the
execution error store
- Extract `useNodeErrorFlagSync` composable for node error flag
reconciliation (previously inlined)
- Extract `useErrorClearingHooks` composable with explicit callback
cleanup on node removal
- Extract `useErrorActions` composable to deduplicate telemetry+command
patterns across error card components
- Move `getCnrIdFromNode`/`getCnrIdFromProperties` to
`platform/nodeReplacement` layer (DDD fix)
- Move `missingNodesErrorStore` to `platform/nodeReplacement` (DDD
alignment)
- Add unmount cancellation guard to `useErrorReport` async `onMounted`
- Return watch stop handle from `useNodeErrorFlagSync`
- Add `asyncResolvedIds` eviction on `missingNodesError` reset
- Add `console.warn` to silent catch blocks and empty array guard
- Hoist `useCommandStore` to setup scope, fix floating promises
- Add `data-testid` to error groups, image/video error spans, copy
button
- Update E2E tests to use scoped locators and testids
- Add unit tests for `onNodeRemoved` restoration and double-install
guard

Fixes #9875, Fixes #10027, Fixes #10033, Fixes #10085

## Test plan

- [x] Existing unit tests pass with updated imports and mocks
- [x] New unit tests for `useErrorClearingHooks` (callback restoration,
double-install guard)
- [x] E2E tests updated to use scoped locators and `data-testid`
- [ ] Manual: verify error tab shows runtime errors and missing nodes
correctly
- [ ] Manual: verify "Find on GitHub", "Copy", and "Get Help" buttons
work in error cards

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10302-refactor-error-system-cleanup-store-separation-DDD-fix-test-improvements-3286d73d365081838279d045b8dd957a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-24 16:43:22 +09:00
jaeone94
32a53eeaee fix: sync advanced inputs button color with node header (#10427)
## Summary
- The "Show Advanced Inputs" footer button was missing `headerColor`
style binding, causing it to not sync with the node header color (unlike
the "Enter Subgraph" button which already had it)
- Extracted the repeated `{ backgroundColor: headerColor }` inline style
(4 occurrences) into a `headerColorStyle` computed

## Screenshots 
before 
<img width="211" height="286" alt="스크린샷 2026-03-24 154312"
src="https://github.com/user-attachments/assets/edfd9480-04fa-4cd4-813d-a95adffbe2d3"
/>

after 
<img width="261" height="333" alt="스크린샷 2026-03-24 154622"
src="https://github.com/user-attachments/assets/eab28717-889e-4a6b-8775-bfc08fa727ff"
/>

## Test plan
- [x] Set a custom color on a node with advanced inputs and verify the
footer button matches the header color
- [x] Verify subgraph enter button still syncs correctly
- [x] Verify dual-tab layouts (error + advanced, error + subgraph) both
show correct colors

### Why no E2E test
Node header color is applied as an inline style via `headerColor` prop,
which is already passed and tested through the existing subgraph enter
button path. This change simply extends the same binding to the advanced
inputs buttons — no new data flow or interaction is introduced, so a
screenshot-based E2E test would add maintenance cost without meaningful
regression coverage.
2026-03-24 16:00:55 +09:00
Jin Yi
7c6ab19484 fix: manager progress toast and install button UX issues (#10423)
## Changes

- Reset `isRestartCompleted` in `closeToast()` so the "Apply Changes"
button appears correctly on subsequent installs instead of skipping to
the success message
- Add `@click.stop` on `PackInstallButton` to prevent click from
bubbling up to card selection, which was unintentionally opening the
right info panel

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10423-fix-manager-progress-toast-and-install-button-UX-issues-32d6d73d365081eba2c6d5b9b35ab26e)
by [Unito](https://www.unito.io)
2026-03-23 21:57:16 -07:00
Dante
8f9fe3b21e refactor: rebuild SingleSelect and MultiSelect with Reka UI (#9742)
## Summary
- Rebuild `SingleSelect` with Reka UI Select primitives (SelectRoot,
SelectTrigger, SelectContent, SelectItem)
- Rebuild `MultiSelect` with Reka UI Popover + Listbox (PopoverRoot,
ListboxRoot with `multiple`)
- Remove PrimeVue dependency from both components
- Preserve existing API contract for all consumers

## TODO
- [x] Re-evaluate MultiSelect implementation (ComboboxRoot with
`multiple` may be cleaner than Popover+Listbox)
- [x] E2E verification in actual app (AssetFilterBar,
WorkflowTemplateSelectorDialog, etc.)

## Test plan
- [x] Storybook visual verification for all SingleSelect/MultiSelect
stories
- [x] Keyboard navigation (arrow keys, Enter, Escape, typeahead)
- [x] Multi-selection with badge count
- [x] Search filtering in MultiSelect
- [x] Clear all functionality
- [x] Disabled state
- [x] Invalid state (SingleSelect)
- [x] Loading state (SingleSelect)

## screenshot
<img width="519" height="475" alt="스크린샷 2026-03-20 오전 12 12 37"
src="https://github.com/user-attachments/assets/ffc7f0b0-c88c-486b-a253-73a4da73c1de"
/>
<img width="842" height="554" alt="스크린샷 2026-03-20 오전 12 23 51"
src="https://github.com/user-attachments/assets/410551d4-c843-4898-b305-13a6ad6978ca"
/>

## video


https://github.com/user-attachments/assets/2fc3a9b9-2671-4c2c-9f54-4f83598afb53



Fixes #9700

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9742-refactor-rebuild-SingleSelect-and-MultiSelect-with-Reka-UI-3206d73d36508113bee2cf160c8f2d50)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 04:39:20 +00:00
Dante
4411d9a417 refactor: extract shared click-vs-drag guard utility (#10357)
## Summary

Extract duplicated click-vs-drag detection logic into a shared
`useClickDragGuard` composable and `exceedsClickThreshold` pure utility
function.

## Changes

- **What**: New `useClickDragGuard(threshold)` composable in
`src/composables/useClickDragGuard.ts` that stores pointer start
position and checks squared distance against a threshold. Also exports
`exceedsClickThreshold` for non-Vue contexts.
- Migrated `DropZone.vue`, `useNodePointerInteractions.ts`, and
`Load3d.ts` to use the shared utility
- `CanvasPointer.ts` left as-is (LiteGraph internal)
- All consumers now use squared-distance comparison (no `Math.sqrt` or
per-axis `Math.abs`)

## Review Focus

- The composable uses plain `let` state instead of `ref` since
reactivity is not needed for the start position
- `Load3d.ts` uses the pure `exceedsClickThreshold` function directly
since it is a class, not a Vue component
- Threshold values preserved per-consumer: DropZone=5,
useNodePointerInteractions=3, Load3d=5

Fixes #10356

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10357-refactor-extract-shared-click-vs-drag-guard-utility-32a6d73d3650816e83f5cb89872fb184)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:27:42 +09:00
Jin Yi
98d56bdada fix: improve settings dialog UX (#10396)
## Summary

Reduce settings dialog size and autofocus search input for better
usability.

## Changes

- **What**: Reduce dialog size from `md` to `sm` (max-width 1400px →
960px); autofocus search input on open

## Review Focus

User feedback indicated the settings dialog was too wide and search
required an extra click to focus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10396-fix-improve-settings-dialog-UX-32c6d73d365081e29eceed55afde1967)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-23 18:45:40 -07:00
Christian Byrne
b4bb6d9d13 feat: add grid view mode for multi-image batches in ImagePreview (#9241)
## Summary

Add grid view mode for multi-image batches in ImagePreview (Nodes 2.0),
replicating the Nodes 1.0 grid UX where all output images are visible as
clickable thumbnails.

## Changes

- **What**: Multi-image batches now default to a grid view showing all
thumbnails. Clicking a thumbnail switches to gallery mode for that
image. A persistent back-to-grid button sits next to navigation dots,
and hover action bars provide gallery toggle, download, and remove.
Replaced PrimeVue `Skeleton` with shadcn `Skeleton`. Added `viewGrid`,
`viewGallery`, `imageCount`, `galleryThumbnail` i18n keys.

## Review Focus

- Grid column count strategy: fixed breakpoints (2 cols ≤4, 3 cols ≤9, 4
cols 10+) vs CSS auto-fill
- Default view mode: grid for multi-image, gallery for single — matches
Nodes 1.0 behavior
- `object-contain` on thumbnails to avoid cropping (with `aspect-square`
containers for uniform cells)

Fixes #9162

<!-- Pipeline-Ticket: f8f8effa-adff-4ede-b1d3-3c4f04b9c4a0 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9241-feat-add-grid-view-mode-for-multi-image-batches-in-ImagePreview-3136d73d36508166895ed6c635150434)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 09:49:44 +09:00
Matt Miller
6a9fb4e1d5 fix: show clear error dialog for 403 whitelist failures (#10402)
## Summary
- When the cloud backend returns a 403 (user not whitelisted), the
frontend showed a generic "Prompt Execution Error" dialog with a cryptic
message
- Now catches 403 responses specifically and shows an "Access
Restricted" dialog with the backend's actual error message
- Adds `status` field to `PromptExecutionError` so error handlers can
distinguish HTTP status codes

## Changes
- `api.ts`: Added optional `status` to `PromptExecutionError`, pass
`res.status` from `queuePrompt`
- `app.ts`: New `else if` branch in the prompt error handler for `status
=== 403` — shows "Access Restricted" with the backend message

## Backwards compatible
- **Old backend** (`"not authorized"`): Shows "Access Restricted: not
authorized"
- **New backend**
([cloud#2941](https://github.com/Comfy-Org/cloud/pull/2941), `"your
account is not whitelisted for this feature"`): Shows "Access
Restricted: your account is not whitelisted for this feature"
- No behavior change for non-403 errors

## Related
- Backend fix: Comfy-Org/cloud#2941
- Notion: COM-16179

## Test plan
- [ ] Submit a prompt as a non-whitelisted user → should see "Access
Restricted" dialog with clear message
- [ ] Submit a prompt as a whitelisted user → no change in behavior
- [ ] Submit a prompt that fails for other reasons (missing nodes, etc.)
→ existing error handling unchanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10402-fix-show-clear-error-dialog-for-403-whitelist-failures-32c6d73d365081eb9528d7feac4e8681)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Matt Miller <mattmiller@Matts-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:57:12 +00:00
Deep Mehta
2838edbb53 feat: send missing node data to ClickHouse (#10132)
## Summary
- When users open/import a workflow with missing nodes, we already track
this via Mixpanel
- This adds a parallel fire-and-forget POST to
`/api/internal/cloud_analytics` so the data also lands in **ClickHouse**
as `frontend:missing_nodes_detected` events
- Payload includes `missing_class_types[]`, `missing_count`, and
`source` (file_button/file_drop/template/unknown)

## Motivation
The frontend is where the **high-value** missing node signal lives —
most users see "missing nodes" and never submit. The backend only
catches the rare case where someone submits anyway. This change captures
both sides.

Companion cloud PR: https://github.com/Comfy-Org/cloud/pull/2886

## Changes
- `MixpanelTelemetryProvider.ts`: Added
`reportMissingNodesToClickHouse()` private method, called from
`trackWorkflowImported()` and `trackWorkflowOpened()`
- Only fires when `missing_node_count > 0`
- Fire-and-forget (`.catch(() => {})`) — no impact on user experience
- Uses existing `api.fetchApi()` which handles auth automatically

## Test plan
- [ ] Open a workflow with missing nodes → verify
`frontend:missing_nodes_detected` event appears in ClickHouse
- [ ] Open a workflow with no missing nodes → verify no event is sent
(check network tab)
- [ ] Verify Mixpanel tracking still works as before

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10132-feat-send-missing-node-data-to-ClickHouse-3266d73d365081559db5ed3efde33e95)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-23 15:39:04 -07:00
Arthur R Longbottom
657ae6a6c3 fix: subgraph promoted widget input label rename (#10195)
## Summary

Promoted primitive subgraph inputs (String, Int) render their link
anchor at the header position instead of the widget row. Renaming
subgraph input labels breaks the match entirely, causing connections to
detach from their widgets visually.

## Changes

- **What**: Fix widget-input slot positioning for promoted subgraph
inputs in both LiteGraph and Vue (Nodes 2.0) rendering modes
- `_arrangeWidgetInputSlots`: Removed Vue mode branch that skipped
setting `input.pos`. Promoted widget inputs aren't rendered as
`<InputSlot>` Vue components (NodeSlots filters them out), so
`input.pos` is the only position fallback
- `drawConnections`: Added pre-pass to arrange nodes with unpositioned
widget-input slots before link rendering. The background canvas renders
before the foreground canvas calls `arrange()`, so positions weren't set
on the first frame
- `SubgraphNode`: Sync `input.widget.name` with the display name on
label rename and initial setup. The `IWidgetLocator` name diverged from
`PromotedWidgetView.name` after rename, breaking all name-based
slot↔widget matching (`_arrangeWidgetInputSlots`, `getWidgetFromSlot`,
`getSlotFromWidget`)

## Review Focus

- The `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs`
directly instead of building a spread-copy map — simpler and avoids the
stale index issue
- `input.widget.name` is now kept in sync with the display name
(`input.label ?? subgraphInput.name`). This is a semantic shift from
using the raw internal name, but it's required for all name-based
matching to work after renames. The value is overwritten on deserialize
by `_setWidget` anyway
- The `_widget` fallback in `_arrangeWidgetInputSlots` is a safety net
for edge cases where the name still doesn't match (e.g., stale cache)

Fixes #9998

## Screenshots
<img width="847" height="476" alt="Screenshot 2026-03-17 at 3 05 32 PM"
src="https://github.com/user-attachments/assets/38f10563-f0bc-44dd-a1a5-f4a7832575d0"
/>
<img width="804" height="471" alt="Screenshot 2026-03-17 at 3 05 23 PM"
src="https://github.com/user-attachments/assets/3237a7ee-f3e5-4084-b330-371def3415bd"
/>
<img width="974" height="571" alt="Screenshot 2026-03-17 at 3 05 16 PM"
src="https://github.com/user-attachments/assets/cafdca46-8d9b-40e1-8561-02cbb25ee8f2"
/>
<img width="967" height="558" alt="Screenshot 2026-03-17 at 3 05 06 PM"
src="https://github.com/user-attachments/assets/fc03ce43-906c-474d-b3bc-ddf08eb37c75"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10195-fix-subgraph-promoted-widget-input-slot-positions-after-label-rename-3266d73d365081dfa623dd94dd87c718)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
2026-03-23 14:33:03 -07:00
Comfy Org PR Bot
f37b0daa3a 1.43.3 (#10225)
Patch version increment to 1.43.3

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-23 14:24:37 -07:00
Terry Jia
debbdfd1f7 test: verify gradient_stops survives object spread (#10408)
## Summary
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/10406 to
add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10408-test-verify-gradient_stops-survives-object-spread-32c6d73d3650815da123c88f4b9b86ce)
by [Unito](https://www.unito.io)
2026-03-23 13:39:49 -07:00
Deep Mehta
9a5ee462f1 refactor: extract model-to-node mappings into separate data file (#10237)
## Summary
- Extract all 75 `quickRegister()` mapping entries from
`modelToNodeStore.ts` into a new `modelNodeMappings.ts` constants file
- The store now iterates over the `MODEL_NODE_MAPPINGS` array instead of
having inline calls
- **Zero behavioral change** — same mappings, same order, same runtime
behavior

## Motivation
Adding new model-to-node mappings is currently a code change to the
store. By separating the data into its own file:
- New mappings are a **pure data change** (append a tuple to an array)
- The data file can have its own CODEOWNERS entry, so mapping PRs can be
merged without requiring frontend team review
- Easier to audit — all mappings visible in one place without
interleaved store logic

### Before
```ts
// 250+ lines of quickRegister() calls mixed into store logic
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
// ... 73 more
```

### After
```ts
// modelNodeMappings.ts — pure data
export const MODEL_NODE_MAPPINGS = [
  ['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
  ['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
  // ...
]

// modelToNodeStore.ts — just iterates
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
  quickRegister(modelType, nodeClass, key)
}
```

## Test plan
- [ ] "Use" button in model browser still works for all model types
- [ ] No regressions in model-to-node resolution (same mappings, same
order)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10237-refactor-extract-model-to-node-mappings-into-separate-data-file-3276d73d365081988656e2ddae772bbc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-23 13:26:42 -07:00
Johnpaul Chiwetelu
cff6a2255c fix: skip scheduled workflow runs on forks (#10407)
Adds `if: github.repository == 'Comfy-Org/ComfyUI_frontend'` to the
`Weekly Documentation Check` and `Release: Version Bump` workflows so
their scheduled runs are skipped on forks (where they fail due to
missing secrets).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10407-fix-skip-scheduled-workflow-runs-on-forks-32c6d73d365081d1b847d9f26b20efeb)
by [Unito](https://www.unito.io)
2026-03-23 20:00:47 +00:00
AustinMroz
860d049487 Fix snap offset for reroutes and subgraph IO (#10229)
When snapping to grid, reroutes and subgraph IO would snap at an awful y
offset.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/285cfd52-2242-4242-b031-926677575542"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/4920ef9b-8eb1-4330-8eea-9992ad662018"
/>|


Regretfully, the PR leaves more magic constants than I would like.
There's a hardcoded `0.7` multiplier on `NODE_SLOT_HEIGHT` that isn't
defined as a constant, and it seems circular imports prevent constants
being used to declare the subgraphIO `roundedRadius`

See #8838 (Sorry for the delay. This change wound up being real simple)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10229-Fix-snap-offset-for-reroutes-and-subgraph-IO-3276d73d365081c2866dc388898a1be4)
by [Unito](https://www.unito.io)
2026-03-23 12:59:47 -07:00
Terry Jia
2d88720d80 fix: make gradient_stops enumerable so it survives object spread (#10406)
## Summary
Object.defineProperty defaults to enumerable: false for new properties.
NodeWidgets.vue spreads widget options into a new object, dropping
non-enumerable properties — gradient_stops was silently lost, causing
the gradient slider to fall back to the default black-to-white gradient.

## Screenshots (if applicable)

before
<img width="1679" height="1271" alt="image"
src="https://github.com/user-attachments/assets/60a20163-56e5-4438-9c36-ea42c23c7495"
/>

after
<img width="1686" height="1421" alt="image"
src="https://github.com/user-attachments/assets/5057b3d4-32ee-4abc-9118-cc2c3f85ae85"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10406-fix-make-gradient_stops-enumerable-so-it-survives-object-spread-32c6d73d36508131b7c8c3ab3e93d221)
by [Unito](https://www.unito.io)
2026-03-23 15:55:47 -04:00
Christian Byrne
818db0d49e fix: use UI flow context for sign_up vs login telemetry (#10388)
## Problem

GA4 shows **zero** `sign_up` events from cloud.comfy.org. All new users
(Google/GitHub) are tracked as `login` instead of `sign_up`.

**Root cause:** `getAdditionalUserInfo(result)?.isNewUser` from Firebase
is unreliable for popup auth flows — it compares `creationDate` vs
`lastSignInDate` timestamps, which can differ even for genuinely new
users. When it returns `null` or `false`, the code defaults to pushing a
`login` event instead of `sign_up`.

**Evidence:** GA4 Exploration filtered to `sign_up` + `cloud.comfy.org`
shows 0 events, while `login` shows 8,804 Google users, 519 email, 193
GitHub — all new users are being misclassified as logins.

(Additionally, the ~300 `sign_up` events visible in GA4 are actually
from `blog.comfy.org` — Substack newsletter subscriptions — not from the
app at all.)

## Fix

Use the UI flow context to determine `is_new_user` instead of Firebase's
unreliable API:
- `CloudSignupView.vue` → passes `{ isNewUser: true }` (user is on the
sign-up page)
- `CloudLoginView.vue` → no flag needed, defaults to `false` (user is on
the login page)
- `SignInContent.vue` → passes `{ isNewUser: !isSignIn.value }` (dialog
toggles between sign-in/sign-up)
- Removes the unused `getAdditionalUserInfo` import

## Changes

- `firebaseAuthStore.ts`: `loginWithGoogle`/`loginWithGithub` accept
optional `{ isNewUser }` parameter instead of calling
`getAdditionalUserInfo`
- `useFirebaseAuthActions.ts`: passes the option through
- `CloudSignupView.vue`: passes `{ isNewUser: true }`
- `SignInContent.vue`: passes `{ isNewUser: !isSignIn.value }`

## Testing

- All 32 `firebaseAuthStore.test.ts` tests pass
- All 19 `GtmTelemetryProvider.test.ts` tests pass
- Typecheck passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10388-fix-use-UI-flow-context-for-sign_up-vs-login-telemetry-32b6d73d3650811e96cec281108abbf3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-23 10:11:40 -07:00
Hunter
cd45efa983 feat: differentiate personal/team pricing table with two-stage team workspace flow (#9901)
## Summary

Differentiates the subscription pricing dialog between personal and team
workspaces with distinct visual treatments and a two-stage team
workspace upgrade flow.

### Changes

- **Personal pricing dialog**: Shows "P" avatar badge, "Plans for
Personal Workspace" header, and "Solo use only – Need team workspace?"
banner on each tier card
- **Team pricing dialog**: Shows workspace avatar, "Plans for Team
Workspace" header (emerald), green "Invite up to X members" badge, and
emerald border on Creator card
- **Two-stage upgrade flow**: "Need team workspace?" → closes pricing →
opens CreateWorkspaceDialog → sessionStorage flag → page reload →
WorkspaceAuthGate auto-opens team pricing dialog
- **Spacing**: Reduced vertical gaps/padding/font sizes so the table
fits without scrolling

### Key decisions

- sessionStorage key `comfy:resume-team-pricing` bridges the page reload
during workspace creation
- `onChooseTeam` prop is conditionally passed only to the personal
variant
- `resumePendingPricingFlow()` is called from WorkspaceAuthGate after
workspace initialization

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9901-feat-differentiate-personal-team-pricing-table-with-two-stage-team-workspace-flow-3226d73d365081e7af60dcca86e83673)
by [Unito](https://www.unito.io)
2026-03-23 09:17:19 -07:00
AustinMroz
bd322314bc Display a separate indicator for pending jobs in app mode (#10382)
Previously, a pending, but not yet running job was displayed as an empty
skeleton. It is instead updated to display an animated `lucide--loader`
icon.

<img width="1544" height="1347" alt="image"
src="https://github.com/user-attachments/assets/4f82185c-97dc-44af-8ea1-a012fb992fe2"
/>

As this shifts the activeQueueItem component to display even when only
the single pending item is in the queue, the count badge is also update
to not display when this is the case.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10382-Display-a-separate-indicator-for-pending-jobs-in-app-mode-32a6d73d36508189b3a9ff4b84993a98)
by [Unito](https://www.unito.io)
2026-03-23 09:09:09 -07:00
Dante
b33c8c2d30 revert: fix: prevent survey popup when surveyed feature is inactive (#10360) (#10393)
## Summary

Reverts #10360

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10393-revert-fix-prevent-survey-popup-when-surveyed-feature-is-inactive-10360-32b6d73d36508132aff9c0e21529efac)
by [Unito](https://www.unito.io)
2026-03-22 14:40:21 +09:00
Simon Pinfold
aa407e7cd4 fix: show all outputs in FormDropdown for multi-output jobs (#10131)
## Summary

FormDropdown Outputs tab only showed the first output for multi-output
jobs because the Jobs API `/jobs` returns a single `preview_output` per
job.

## Changes

- **What**: When history assets include jobs with `outputs_count > 1`,
lazily fetch full outputs via `getJobDetail` (cached in
`jobOutputCache`) and expand them into individual dropdown items.
Single-output jobs are unaffected. Added in-flight guard to prevent
duplicate fetches.
- This is a consumer-side workaround in `WidgetSelectDropdown.vue` that
becomes a no-op once the backend returns all outputs in the list
response (planned Assets API migration).

## Review Focus

- The `resolvedMultiOutputs` shallowRef + watch pattern for async data
feeding into a computed. Each `getJobDetail` call is cached by
`jobOutputCache` LRU, so no redundant network requests.
- This fix is intentionally temporary — it will be superseded when
OSS/cloud both return full outputs from list endpoints.

## No E2E test

E2E coverage is impractical here: reproducing requires a running ComfyUI
backend executing a workflow that produces multiple outputs, then
inspecting the FormDropdown's Outputs tab. The unit test covers the
lazy-loading logic with mocked `getJobDetail` responses.

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-21 16:40:30 -07:00
Dante
a9dce7aa20 fix: prevent survey popup when surveyed feature is inactive (#10360)
## Summary

Prevent the nightly survey popup from appearing when the feature being
surveyed is currently disabled.

## Root Cause

`useSurveyEligibility` checks the feature usage count from localStorage
but does not verify whether the surveyed feature is currently active.
When a user uses the new node search 3+ times (reaching the survey
threshold), then switches back to legacy search, the usage count
persists in localStorage and the survey popup still appears despite the
feature being off.

## Steps to Reproduce

1. Enable new node search (Settings > Node Search Box Implementation >
"default")
2. Use node search 3+ times (double-click canvas, search for nodes)
3. Switch back to legacy search (Settings > Node Search Box
Implementation > "litegraph (legacy)")
4. Wait 5 seconds on a nightly localhost build
5. **Expected**: No survey popup appears (feature is disabled)
6. **Actual**: Survey popup appears because eligibility only checks
stored usage count, not current feature state

## Changes

1. Added optional `isFeatureActive` callback to `FeatureSurveyConfig`
interface
2. Added `isFeatureActive` guard in `useSurveyEligibility` eligibility
computation
3. Configured `node-search` survey with `isFeatureActive` that checks
the current search box setting

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `99e7d7fe9` `test:` | RED | Asserts `isEligible` is false when
`isFeatureActive` returns false. Fails on current code. |
| `01df9af12` `fix:` | GREEN | Adds `isFeatureActive` check to
eligibility. Test passes. |

Fixes #10333
2026-03-22 07:38:41 +09:00
Alexander Brown
20738b6349 test: add Vue Testing Library infrastructure and pilot migration (#10319)
## Summary

Add Vue Testing Library (VTL) infrastructure and pilot-migrate
ComfyQueueButton.test.ts as Phase 0 of an incremental VTL adoption.

## Changes

- **What**: Install `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`, and
`eslint-plugin-testing-library`. Configure jest-dom matchers globally
via `vitest.setup.ts` and `tsconfig.json`. Create shared render wrapper
at `src/utils/test-utils.ts` (pre-configures PrimeVue, Pinia, i18n).
Migrate `ComfyQueueButton.test.ts` from `@vue/test-utils` to VTL. Add
warn-level `testing-library/*` ESLint rules for test files.
- **Dependencies**: `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`,
`eslint-plugin-testing-library`

## Review Focus

- `src/utils/test-utils.ts` — shared render wrapper typing approach
(uses `ComponentMountingOptions` from VTU since VTL's `RenderOptions`
requires a generic parameter)
- ESLint rules are all set to `warn` during migration to avoid breaking
existing VTU tests
- VTL coexists with VTU — no existing tests are broken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10319-test-add-Vue-Testing-Library-infrastructure-and-pilot-migration-3286d73d3650812793ccd8a839550a04)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-21 13:40:15 -07:00
Alexander Brown
477f9c7631 chore: upgrade nx 22.5.2 → 22.6.1 (#10370)
## Summary

Upgrade Nx from 22.5.2 to 22.6.1.

## Changes

- **What**: Bumped nx, @nx/eslint, @nx/playwright, @nx/storybook, and
@nx/vite from 22.5.2 to 22.6.1.
- **Dependencies**: nx, @nx/eslint, @nx/playwright, @nx/storybook,
@nx/vite updated to 22.6.1.

## Review Focus

All Nx migrations ran with no changes needed — this is a straightforward
version bump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10370-chore-upgrade-nx-22-5-2-22-6-1-32a6d73d36508191988bdc7376dc5e14)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 17:41:36 +00:00
pythongosssss
dee494f019 feat: App mode - Switch to Nodes 2.0 when entering builder (#10337)
## Summary

We've had some reports of issues selecting inputs/nodes when trying to
use the builder in LiteGraph mode and due to the complexity of the
canvas system, we're going to enable Nodes 2.0 when entering the builder
to ensure the best experience.

## Changes

- **What**:  
- When entering builder select mode automatically switch to Nodes 2.0
- Extract reusable component from features toast
- Show popup telling user the mode was changed
- Add hidden setting for storing "don't show again" on the switch popup

## Review Focus
- I have not removed the LiteGraph selection code in case someone still
manages to enter the builder in LiteGraph mode, this should be cleaned
up in future

## Screenshots (if applicable)

<img width="423" height="224" alt="image"
src="https://github.com/user-attachments/assets/cc2591bc-e5dc-47ef-a3c6-91ca7b6066ff"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10337-feat-App-mode-Switch-to-Nodes-2-0-when-entering-builder-3296d73d3650818e9f3cdaac59d15609)
by [Unito](https://www.unito.io)
2026-03-21 10:38:59 -07:00
pythongosssss
39864b67d8 feat: App mode - Show filename on previews (#10364)
## Summary

Shows the name of the file allowing users to update the filename_prefix
widget on nodes to easier identify which output is for which node

## Changes

- **What**: Pass output display name or filename and use as label

## Screenshots (if applicable)

<img width="586" height="203" alt="image"
src="https://github.com/user-attachments/assets/8d989e61-aa47-4644-8738-159dbf4430e0"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10364-feat-App-mode-Show-filename-on-previews-32a6d73d36508133b53df619e8f0bea1)
by [Unito](https://www.unito.io)
2026-03-21 10:28:24 -07:00
Alexander Brown
32bd570855 chore: clean up knip config, upgrade to v6, remove unused exports (#10348)
## Summary

Clean up stale knip configuration, upgrade to v6, and remove unused
exports.

## Changes

- **What**: Upgrade knip 5.75.1 → 6.0.1; remove 13 stale/redundant
config entries; remove custom CSS compiler (replaced by v6 built-in);
move CSS plugin deps to design-system package; fix husky pre-commit
binary detection; remove 3 unused exports (`MaintenanceTaskRunner`,
`ClipspaceDialog`, `Load3dService`)
- **Dependencies**: knip ^5.75.1 → ^6.0.1; tailwindcss-primeui and
tw-animate-css moved from root to packages/design-system

## Review Focus

- The husky pre-commit change from `pnpm exec lint-staged` to `npx
--no-install lint-staged` works around a knip script parser limitation
([knip#743](https://github.com/webpro-nl/knip/issues/743))
- knip v6 drops Node.js 18 support (requires ≥20.19.0) and removes
`classMembers` issue type

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 09:43:14 -07:00
Yourz
811d58aef7 feat: enable Quiver AI icon for partner nodes (#10366)
## Summary

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

Enable Quiver AI icon for partner nodes

## Changes

- **What**: <!-- Core functionality added/modified -->
  - Enable  Quiver AI icon for partner nodes

No Quiver AI nodes now, just mock the data to see how it will look
<img width="1062" height="926" alt="image"
src="https://github.com/user-attachments/assets/1b81a1cc-d72c-413d-bd75-a63925c27a4b"
/>


## Review Focus
When Quiver AI nodes provided, the icon should show at both node library
tree, but also the PreviewCard label.


<!-- 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-10366-feat-enable-Quiver-AI-icon-for-partner-nodes-32a6d73d365081d4801ec2619bd2c77c)
by [Unito](https://www.unito.io)
2026-03-21 23:29:09 +08:00
Jin Yi
7d9fa2bfc5 feat: add WaveAudioPlayer with waveform visualization and authenticated audio fetch (#10158)
## Summary

Add a waveform-based audio player component (`WaveAudioPlayer`)
replacing the native `<audio>` element, with authenticated API fetch for
cloud audio playback.

## Changes

- **What**:
- Add `useWaveAudioPlayer` composable with waveform visualization from
audio data (Web Audio API `decodeAudioData`), playback controls, and
seek support
- Add `WaveAudioPlayer.vue` component with compact (inline waveform +
time) and expanded (full transport controls) variants
- Replace native `<audio>` in `MediaAudioTop.vue` and `ResultAudio.vue`
with `WaveAudioPlayer`
- Use `api.fetchApi()` instead of bare `fetch()` to include Firebase JWT
auth headers, fixing 401 errors in cloud environments
  - Add Storybook stories and unit tests

## Review Focus

- The audio URL is fetched via `api.fetchApi()` with auth headers,
converted to a Blob URL, then passed to the native `<audio>` element.
This avoids 401 Unauthorized in cloud environments where `/api/view`
requires authentication.
- URL-to-route extraction logic (`url.includes(apiBase)`) handles both
full API URLs and relative paths.


[screen-capture.webm](https://github.com/user-attachments/assets/44e61812-0391-4b47-a199-92927e75f8b4)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10158-feat-add-WaveAudioPlayer-with-waveform-visualization-and-authenticated-audio-fetch-3266d73d365081beab3fc6274c39fcd4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-20 16:48:28 -07:00
AustinMroz
2b51babbcd Don't use reactives for app mode selections (#10342)
- The DraggableList component takes a v-model.
- When a drag is completed, it reassigns to v-model so an update event
fires
  - App Builder sets v-model="appModeStore.selectedInputs"
- Thus a completed drag operation effectively performs
appModeStore.selectedInputs = newList
- In appModeStore, selectedInputs is a reactive. Thus, after a drag/drop
operation occurs, selectedInputs in the store is not the same value as
appModeStore.selectedInputs

When a reliable repro for the issue had not yet been found, an attempted
earlier fix for this was to swap from watchers to directly updating
`selectedInputs`. Since this change makes the code cleaner and still
make the code safer, the change is left in place.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10342-Don-t-use-watcher-for-loading-app-mode-selections-3296d73d365081f7b216e79c4f4f4e8d)
by [Unito](https://www.unito.io)
2026-03-20 16:07:20 -07:00
jaeone94
cc0ba2d471 refactor: extract helpers from _removeDuplicateLinks and add integration tests (#10332) 2026-03-21 08:03:55 +09:00
pythongosssss
c90a5402b4 feat: App mode - double click to rename widget (#10341)
## Summary

Allows users to rename widgets by double clicking the label

## Changes

- **What**: Uses EditableText component to allow inline renaming

## Screenshots (if applicable)


https://github.com/user-attachments/assets/f5cbb908-14cf-4dfa-8eb2-1024284effef

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10341-feat-App-mode-double-click-to-rename-widget-3296d73d36508146bbccf8c29f56dc96)
by [Unito](https://www.unito.io)
2026-03-20 14:35:09 -07:00
pythongosssss
7501a3eefc fix: App mode - Widget dropdowns clipped in sidebar (#10338)
## Summary

Popover components for graph mode are appendTo self so scale/translate
works, however in the sidebar this causes them to be clipped by the
parent overflow. This adds a provide/inject flag to change these to be
appended to the body.

## Changes

- **What**: 
- add append to injection for overriding where popovers are mounted
- ensure dropdowns respect this flag
- extract enterAppModeWithInputs helper
- tests

Before:  
<img width="225" height="140" alt="image"
src="https://github.com/user-attachments/assets/bd83b0cd-49a9-45dd-8344-4c10221444fc"
/>

After:  
<img width="238" height="225" alt="image"
src="https://github.com/user-attachments/assets/286e28e9-b37d-4ffc-91a9-7c340757d3fc"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10338-fix-App-mode-Widget-dropdowns-clipped-in-sidebar-3296d73d365081e2ba38e3e82006d65e)
by [Unito](https://www.unito.io)
2026-03-20 14:18:54 -07:00
Matt Miller
3b85227089 fix: hide API key login option on Cloud (#10343)
## Summary
- Hide the "Comfy API Key" login button and help text from the sign-in
modal when running on Cloud
- API key auth is only used on local ComfyUI; Cloud has
Firebase-whitelisted Google/GitHub auth
- The button was appearing on Cloud as a relic of shared code between
Cloud and local ComfyUI

## Context
[Discussion with Robin in
#proj-cloud-frontend](https://comfy-organization.slack.com/archives/C09FY39CC3V/p1773977756997559)
— API key auth only works on local because Firebase requires whitelisted
domains. On Cloud, SSO (Google/GitHub) works natively, so the API key
option is unnecessary and confusing.

<img width="470" height="865" alt="Screenshot 2026-03-20 at 9 53 20 AM"
src="https://github.com/user-attachments/assets/5bbdcbaf-243c-48c6-9bd0-aaae815925ea"
/>

## Test plan
- [ ] Verify login modal on local ComfyUI still shows the "Comfy API
Key" button
- [ ] Verify login modal on cloud.comfy.org no longer shows the "Comfy
API Key" button
2026-03-20 10:14:36 -07:00
Johnpaul Chiwetelu
944f78adf4 feat: import/export keybinding presets (#9681)
## Summary
- Add keybinding preset system: save, load, switch, import, export, and
delete named keybinding sets stored via `/api/userdata/keybindings/`
- Preset selector dropdown with "Save Changes" button for modified
custom presets, and "Import keybinding preset" action
- More-options menu in header row with save as new, reset, delete,
import, and export actions
- Search box and menu teleported to settings dialog header (matching
templates modal layout)
- 11 unit tests for preset service CRUD operations

Fixes #1084
Fixes #1085

## Test plan
- [ ] Open Settings > Keybinding, verify search box and "..." menu
appear in header
- [ ] Modify a keybinding, verify "Default *" shows modified indicator
- [ ] Use "Save as new preset" from menu, verify preset appears in
dropdown
- [ ] Switch between presets, verify unsaved changes prompt
- [ ] Export preset, import it back, verify bindings restored
- [ ] Delete a custom preset, verify reset to default
- [ ] Verify "Save Changes" button appears only on modified custom
presets
- [ ] Run `pnpm vitest run
src/platform/keybindings/presetService.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9681-feat-import-export-keybinding-presets-31e6d73d3650810f88e4d21b3df3e2dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-20 12:24:31 +01:00
jaeone94
0106372201 feat: enhance error tab with full error report and GitHub search (#10135) 2026-03-20 18:21:16 +09:00
Yourz
e2491fe32e fix: search media assets by display_name in addition to name (#10254)
## Description

On cloud, asset filenames are content hashes (e.g. `16bcbbe5...png`)
while the UI shows `display_name` (e.g. `ComfyUI_00011_`). Fuse.js was
only searching the `name` field, so typing the visible name in the Media
Assets search returned no results.

## Changes

### What
- Add `display_name` to the Fuse.js search keys in
`useMediaAssetFiltering`, so users can search by the name they actually
see in the panel.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10254-fix-search-media-assets-by-display_name-in-addition-to-name-3276d73d3650813dbaf0ccd9d57ccfa3)
by [Unito](https://www.unito.io/)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-20 16:02:23 +08:00
Johnpaul Chiwetelu
8aab2889b7 test: add copy/paste node and image paste e2e tests (#10233)
## Summary
- Add e2e test covering node copy/paste (Ctrl+C/V), image paste onto a
selected LoadImage node, and image paste on empty canvas creating a new
LoadImage node
- Extract `simulateImagePaste` into reusable
`ClipboardHelper.pasteFile()` with auto-detected filename and MIME type
- Add test workflow asset `load_image_with_ksampler.json` and
`image32x32.webp` image asset

## Test plan
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm exec eslint browser_tests/tests/copyPaste.spec.ts` passes
- [ ] New test passes in CI: `Copy paste node, image paste onto
LoadImage, image paste on empty canvas`
- [ ] Existing copy/paste tests unaffected

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10233-test-add-copy-paste-node-and-image-paste-e2e-tests-3276d73d365081e78bb9f3f1bf34389f)
by [Unito](https://www.unito.io)
2026-03-20 03:24:09 +01:00
Alexander Brown
4d57c41fdb test: subgraph integration contracts and expanded Playwright coverage (#10123)
## Summary

Add integration contract tests (unit) and expanded Playwright coverage
for subgraph promotion, hydration, navigation, and lifecycle edge
behaviors.

## Changes

- **What**: 22 unit/integration tests across 9 files covering promotion
store sync, widget view lifecycle, input link resolution, pseudo-widget
cache, navigation viewport restore, and subgraph operations. 13
Playwright E2E tests covering proxyWidgets hydration stability, promoted
source removal cleanup, pseudo-preview unpack/remove, multi-link
representative round-trip, nested promotion retarget, and navigation
state on workflow switch.
- **Helpers**: Added `isPseudoPreviewEntry`, `getPseudoPreviewWidgets`,
`getNonPreviewPromotedWidgets` to promotedWidgets helper. Added
`SubgraphHelper.getNodeCount()`.

## Review Focus

- Test-only PR — no production code changes
- Validates existing subgraph behaviors are covered by regression tests
before further feature work
- Phase 4 (unit/integration contracts) and Phase 5 (Playwright
expansion) of the subgraph test coverage plan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10123-test-subgraph-integration-contracts-and-expanded-Playwright-coverage-3256d73d365081258023e3a763859e00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-19 23:54:15 +00:00
Alexander Brown
6dfc7b4306 perf: eliminate double page navigation in Playwright test setup (#10313)
## Summary

Eliminate the double `goto()` in `ComfyPage.setup()` by using
`addInitScript` to seed localStorage before the first navigation.

## Changes

- **What**: Move route mocking and localStorage seeding before `goto()`,
replacing `page.evaluate` with `page.addInitScript` so values are set
before app JS executes on first load. Removes the second `goto()` call
entirely.

## Review Focus

- Verify all Playwright E2E shards pass — the `clearStorage: false` and
`mockReleases: false` paths should be unaffected.
- Note: `addInitScript` persists for the page lifetime (like
`FeatureFlagHelper.seedFlags` already does), so any subsequent
`page.reload()` or `goto()` in tests will also clear storage. This
should be fine since tests that use `clearStorage: false` skip this
block.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10313-perf-eliminate-double-page-navigation-in-Playwright-test-setup-3286d73d36508158a8edeefb27fcae20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 16:53:07 -07:00
Alexander Brown
28a91fa8b6 fix: configure nested subgraph definitions in dependency order (#10314)
## Summary

Fix workflow loading for nested subgraphs with duplicate node IDs by
configuring subgraph definitions in topological (leaf-first) order.

## Changes

- **What**: Three pre-existing bugs that surface when loading nested
subgraphs with colliding node IDs:
1. Subgraph definitions configured in serialization order — a parent
subgraph's `SubgraphNode.configure` would run before its referenced
child subgraph was populated, causing link/widget resolution failures.
2. `_resolveLegacyEntry` returned `undefined` when `input._widget`
wasn't set yet, instead of falling back to `resolveSubgraphInputTarget`.
3. `_removeDuplicateLinks` removed duplicate links without updating
`SubgraphOutput.linkIds`, leaving stale references that broke prompt
execution.
- **What (housekeeping)**: Moved `subgraphDeduplication.ts` from
`utils/` to `subgraph/` directory where it belongs.

## Review Focus

- Topological sort correctness: Kahn's algorithm with edges from
dependency→dependent ensures leaves configure first. Cycle fallback
returns original order.
- IO slot link repair: `_repairIOSlotLinkIds` runs after
`_configureSubgraph` creates IO slots, patching any `linkIds` that point
to links removed by `_removeDuplicateLinks` during `super.configure()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10314-fix-configure-nested-subgraph-definitions-in-dependency-order-3286d73d36508171b149e238b8de84c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 15:36:26 -07:00
AustinMroz
4eded7c82b App Mode dragAndDrop, text output, and scroll shadows (#10122)
- When in app mode, workflows can be loaded by dragging and dropping as
elsewhere.
- Dragging a file which is supported by a selected app input to the
center panel will apply drop effects on the specific input
  - This overrides the loading of workflows
- There's not currently an indicator for where the image will go. This
is being considered for a followup PR
- Outputs can be dragged from the assets panel onto nodes
  - This fixes behaviour outside of app mode as well
  - Has some thorny implementation specifics
- Non-core nodes may not be able to accept these inputs without an
update
- Node DragOver filtering has reduced functionality when dragging from
the assets pane. Nodes may have the blue border without being able to
accept a drag operation.
- When dropped onto the canvas, the workflow will load (a fix), but the
workflow name will be the url of the image preview
  -  The entire card is used for the drag preview
<img width="329" height="380" alt="image"
src="https://github.com/user-attachments/assets/2945f9a3-3e77-4e14-a812-4a361976390d"
/>
- Adds a new scroll-shadows tailwind util as an indicator that more
content is available by scrolling.
- Since a primary goal was preventing API costs overflowing, I've made
the indicator fairly strong. This can be tuned later if needed

![scroll-shadow_00001](https://github.com/user-attachments/assets/e683d329-4283-4d06-aa29-5eee48030f27)
- Initial support for text outputs in App Mode
- Also causes jobs with text outputs to incorrectly display in the
assets panel with a generic 'check' icon instead of a text specific
icon. This will need a dedicated pass, but shouldn't be overly onerous
in the interim.
<img width="1209" height="735" alt="text output"
src="https://github.com/user-attachments/assets/fcd1cf9f-5d5c-434c-acd0-58d248237b99"
/>
  
NOTE: Displaying text outputs conflicted with the changes in #9622. I'll
leave text output still disabled in this PR and open a new PR for
reconciling text as an output so it can go through dedicated review.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10122-App-Mode-dragAndDrop-text-output-and-scroll-shadows-3256d73d3650810caaf8d75de94388c9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-19 14:20:35 -07:00
pythongosssss
57783fffcf feat: App mode - add lightbox to view image in drop zone (#9888)
## Summary

Adds a button to view image in lightbox in app mode

## Changes

- **What**: 
- Add generic image lightbox component
- Add zoom button to dropzone
- Move buttons to outside image layer to top right of drop zone

## Screenshots (if applicable)

<img width="1164" height="838" alt="image"
src="https://github.com/user-attachments/assets/c92f2227-9dc0-49bd-bb27-5211e22060be"
/>
<img width="290" height="199" alt="image"
src="https://github.com/user-attachments/assets/90424b8e-c502-4d65-ad21-545d5add6d0b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9888-feat-App-mode-add-lightbox-to-view-image-in-drop-zone-3226d73d365081c387a6c2dd24dc4100)
by [Unito](https://www.unito.io)
2026-03-19 09:22:06 -07:00
pythongosssss
be6c64c75b feat: Add edge panning to ghost node placement (#10308)
## Summary

Enables edge panning when placing a ghost-node from the search

## Changes

- **What**: 
- registers document level listeners so dragging over UI elements isnt
blocked

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c3bda3c5-8255-4dda-a6be-6ef306af2244

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10308-feat-Add-edge-panning-to-ghost-node-placement-3286d73d36508151b645ec3e860bc017)
by [Unito](https://www.unito.io)
2026-03-19 09:21:01 -07:00
Yourz
43ba0a9a14 fix: set topbar menus to non-modal so they dismiss on canvas interaction (#10310)
## Summary

Set topbar menus to non-modal so they dismiss when clicking
inputs/textareas inside nodes with Nodes 2.0 enabled.

## Changes

- **What**: Add `:modal="false"` to `ContextMenuRoot` in WorkflowTab and
`DropdownMenuRoot` in WorkflowActionsDropdown.

![Kapture 2026-03-19 at 23 50
43](https://github.com/user-attachments/assets/f423f3df-48d0-42c4-b2bc-d19feffb9028)


## Review Focus

Modal reka-ui menus set `body.pointer-events: none` and prevent
`focusOutside` dismissal. With Nodes 2.0, widget components use
`@pointerdown.capture.stop` to prevent node dragging, which also blocks
reka-ui's document-level outside-click detection. Non-modal menus allow
`focusin`-based dismissal, which is unaffected by pointerdown stopping.

## Testing

An E2E regression test for this fix requires Nodes 2.0 to be explicitly
enabled (feature-flag guarded), opening a specific topbar menu, and then
clicking inside a canvas node's textarea — an interaction sequence that
has no existing Playwright fixture/helper pattern in the codebase; the
fix itself is a one-line :modal="false" attribute change on reka-ui
primitives whose behavior is documented and tested upstream.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10310-fix-set-topbar-menus-to-non-modal-so-they-dismiss-on-canvas-interaction-3286d73d3650815287d1c66c6ffd4814)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-20 00:17:28 +08:00
AustinMroz
157f0598d4 Display optional indicator on subgraphNode inputs (#8772)
Display an optional input on subgraphNodes when all links to the
subgraph input are optional.
<img width="1132" height="625" alt="image"
src="https://github.com/user-attachments/assets/e87d9d33-4b95-4ec9-afb8-bc1fb8cc0638"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8772-Display-optional-indicator-on-subgraphNode-inputs-3036d73d365081cc83e8d6034302665f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-19 09:03:22 -07:00
pythongosssss
3ed88fbe68 Autopan canvas when dragging nodes/links to edges (#8773)
## Summary

Adds autopanning so the canvas moves when you drag a node/link to the
side of the canvas

## Changes

- **What**: 
- adds autopan controller that runs on animation frame timer to check
autopan speed
- extracts updateNodePositions for reuse
- specific handling for vue vs litegraph modes
- adds max speed setting, allowing user to set 0 for disabling

## Screenshots (if applicable)

https://github.com/user-attachments/assets/1290ae6d-b2f0-4d63-8955-39b933526874

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8773-Autopan-canvas-when-dragging-nodes-links-to-edges-3036d73d365081869a58ca5978f15f80)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-19 04:12:06 -07:00
pythongosssss
77ddda9d3c fix: App mode - renaming widgets on subgraphs (#10245)
## Summary

Fixes renaming of widgets from subgraph nodes in app builder/app mode.

## Changes

- **What**: If the widget is from a subgraph node and no parents passed,
use the node as the subgraph parent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10245-fix-App-mode-renaming-widgets-on-subgraphs-3276d73d3650815bb131c840df43cdf2)
by [Unito](https://www.unito.io)
2026-03-19 04:00:31 -07:00
jaeone94
3591579141 fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10289)
## Summary

Fixes a regression introduced in v1.41.21 where
`_removeDuplicateLinks()` (added by #9120 / backport #10045) incorrectly
removes valid links during workflow loading when the target node has
widget-to-input conversions that shift slot indices.

- Fixes https://github.com/Comfy-Org/workflow_templates/issues/715

## Root Cause

The `_removeDuplicateLinks()` method added in #9120 uses
`node.inputs[link.target_slot]` to determine which duplicate link to
keep. However, `target_slot` is the slot index recorded at serialization
time. During `LGraphNode.configure()`, the `onConnectionsChange`
callback triggers widget-to-input conversions (e.g., KSamplerAdvanced
converting `steps`, `cfg`, `start_at_step`, etc.), which inserts new
entries into the `inputs` array. This shifts indices so that
`node.inputs[target_slot]` no longer points to the expected input.

**Concrete example with `video_wan2_2_14B_i2v.json`:**

The Wan2.2 Image-to-Video subgraph contains a Switch node (id=120)
connected to KSamplerAdvanced (id=85) cfg input. The serialized data has
two links with the same connection tuple `(origin_id=120, origin_slot=0,
target_id=85, target_slot=5)`:

| Link ID | Connection | Status |
|---------|-----------|--------|
| 257 | 120:0 → 85:5 (FLOAT) | Orphaned duplicate (not referenced by any
input.link) |
| 276 | 120:0 → 85:5 (FLOAT) | Valid (referenced by node 85
input.link=276) |

When `_removeDuplicateLinks()` runs after all nodes are configured:
1. KSamplerAdvanced is created with 4 default inputs, but after
`configure()` with widget conversions, it has **13 inputs** (shifted
indices)
2. The method checks `node.inputs[5].link` (target_slot=5 from the
LLink), but index 5 is now a different input due to the shift
3. `node.inputs[5].link === null` → the method incorrectly decides link
276 is not referenced
4. **Link 276 (valid) is removed, link 257 (orphan) is kept** →
connection lost

This worked correctly in v1.41.20 because `_removeDuplicateLinks()` did
not exist.

## Changes

Replace the `target_slot`-based positional lookup with a full scan of
the target node's inputs to find which duplicate link ID is actually
referenced by `input.link`. Also repair `input.link` if it still points
to a removed duplicate after cleanup.

## Test Plan

- [x] Added regression test: shifted slot index scenario
(widget-to-input conversion)
- [x] Added regression test: `input.link` repair when pointing to
removed duplicate
- [x] Existing `_removeDuplicateLinks` tests pass (45/45)
- [x] Full unit test suite passes (6885/6885)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes (0 errors)
- [x] Manual verification: loaded `video_wan2_2_14B_i2v.json` in clean
state — Switch→KSamplerAdvanced cfg link is now preserved
- [ ] E2E testing is difficult for this fix since it requires a workflow
with duplicate links in a subgraph containing nodes with widget-to-input
conversions (e.g., KSamplerAdvanced). The specific conditions —
duplicate LLink entries + slot index shift from widget conversion — are
hard to set up in Playwright without a pre-crafted fixture workflow and
backend node type registration. The unit tests cover the core logic
directly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10289-fix-_removeDuplicateLinks-incorrectly-removes-valid-link-when-slot-indices-shift-3286d73d36508140b053fd538163e383)
by [Unito](https://www.unito.io)
2026-03-19 15:45:24 +09:00
Alexander Brown
d17810440f fix: resync slot layouts when switching between app mode and graph mode (#10273)
## Summary

Fix broken link rendering (noodles disappearing or going to wrong
positions) when switching between app mode and graph mode tabs.

## Changes

- **What**: When the graph canvas is hidden via `display: none` in app
mode, slot elements lose valid DOM measurements. On switching back,
links rendered at stale coordinates or disappeared. This PR rekeys
`LGraphNode` components by workflow path, adds measurability guards to
skip hidden slots, clears stale layouts, and watches `linearMode` to
trigger a full slot layout resync on mode transitions.

## Review Focus

- The `isSlotElementMeasurable` guard skips elements that are
disconnected or have zero-size rects — verify this doesn't inadvertently
skip slots during normal graph rendering.
- The `linearMode` watcher clears all slot layouts when entering app
mode and requests a full resync when leaving — confirm no flicker or
race with the RAF-based sync scheduler.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10273-fix-resync-slot-layouts-when-switching-between-app-mode-and-graph-mode-3276d73d3650812f9366dae53c7b2d37)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 16:38:08 -07:00
Christian Byrne
6c14802425 feat: fire subscription_success telemetry on subscription activation (#10186)
## Summary

Fire a client-side `subscription_success` event to GTM when a
subscription activates, enabling LinkedIn and Meta conversion tracking
tags.

## Changes

- **What**: Wire `trackMonthlySubscriptionSucceeded()` into both
subscription detection paths (legacy dialog watcher + workspace
billingOperationStore). This method existed but had zero call sites
(dead code). The event name remains `subscription_success` (not
`purchase`) to avoid double-counting with the server-side GA4
Measurement Protocol purchase event and the existing Google Ads Purchase
conversion tag in GTM.

## Review Focus

- billingOperationStore only fires telemetry for `subscription`
operations, not `topup`.
- Legacy flow: telemetry fires in the existing `isActiveSubscription`
watcher, before `emit("close", true)`.
- Workspace flow: telemetry fires in `handleSuccess()` after status
update but before billing context refresh.
- No event name change: `subscription_success` was the original name and
avoids collision with the server-side `purchase` event.

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-03-18 15:51:13 -07:00
Christian Byrne
4ba85dd3ef fix: make graph canvas toolbar visible on mobile (#10168)
## Summary

Fix the bottom-right graph canvas toolbar (zoom controls, fit-to-view,
minimap toggle) not being visible on mobile devices in normal graph
mode.

## Problem

PrimeVue applies `overflow: hidden` to all `.p-splitterpanel` elements
by default. The `GraphCanvasMenu` component is absolutely positioned
(`right-0 bottom-0`) inside the `graph-canvas-panel` SplitterPanel. On
mobile viewports, the panel's bounding box can be smaller than the full
canvas area, causing the toolbar to be clipped by the `overflow:
hidden`.

## Solution

Add `overflow-visible` to the `graph-canvas-panel` SplitterPanel class
to override PrimeVue's default `overflow: hidden`. This allows the
absolutely-positioned toolbar (and minimap) to remain visible regardless
of viewport size.

<img width="873" height="1056" alt="image"
src="https://github.com/user-attachments/assets/7239a5ce-8ce8-4e1d-a8ff-6d6d3c61f5da"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10168-fix-make-graph-canvas-toolbar-visible-on-mobile-3266d73d36508130b675e839cb748fd5)
by [Unito](https://www.unito.io)
2026-03-18 15:31:29 -07:00
Alexander Brown
2d44e9d2c0 fix: resync vue node layout store after legacy normalization (#10256)
## Summary
Fixes a regression introduced in #9680 where groups and nodes could
render in different coordinate spaces when loading legacy
`workflowRendererVersion: "Vue"` workflows with Vue nodes mode enabled.

- Add post-normalization sync to copy normalized LiteGraph node bounds
into `layoutStore` during `loadGraphData`
- Keep sync scoped to Vue nodes mode and only when normalization
actually ran
- Add unit tests for the new layout-store sync helper
- Add Playwright regression coverage for legacy Vue workflow load path
using `groups/nested-groups-1-inner-node`, asserting node/group
centering-gap distances remain within baseline tolerances

## Testing
- pnpm test:unit
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.test.ts
- pnpm test:unit
src/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale.test.ts
- pnpm exec eslint
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.ts
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.test.ts
- pnpm exec oxlint src/scripts/app.ts
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.ts
src/renderer/core/layout/sync/syncLayoutStoreFromGraph.test.ts
- pnpm typecheck
- pnpm typecheck:browser
- pnpm exec eslint browser_tests/tests/vueNodes/groups/groups.spec.ts
- pnpm exec oxlint browser_tests/tests/vueNodes/groups/groups.spec.ts
- pnpm exec playwright test
browser_tests/tests/vueNodes/groups/groups.spec.ts --grep "legacy Vue
workflows"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10256-fix-resync-vue-node-layout-store-after-legacy-normalization-3276d73d365081568eebc6aa0827d943)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 12:05:20 -07:00
AustinMroz
efa1b68c38 Allow graph navigation by browser forward/backward (#6811)
- On graph change, set the `graph.id` as location hash
- On hash change, navigate to the target `graph.id` either in the
current, or any other loaded workflow.

`canvasStore.currentGraph` does not trigger when `app.loadGraphData` is
called. A trigger could be forced here, but I'm concerned about side
effects. Instead `updateHash` is manually called.

Code search shows that there are no current custom nodes using
`onhashchange`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6811-Allow-graph-navigation-by-browser-forward-backward-2b26d73d365081bb8414fdf7c3686124)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 11:21:57 -07:00
pythongosssss
0af1ffdf1a fix: App mode - Move active output spinner/items outside scrollable area (#10243)
## Summary

Moves the in progress job items outside the scrollable area to remove
requirement for background on that section causing visual bug when
zoomed in
<img width="413" height="121" alt="image"
src="https://github.com/user-attachments/assets/4ad85260-1ab0-48c5-8625-7d5f67b1ccf8"
/>


## Changes
- **What**:  Restructure to outside rather than using sticky

## Screenshots (if applicable)

Overlapping image:
<img width="1143" height="91" alt="Screenshot 2026-03-18 043135"
src="https://github.com/user-attachments/assets/f7140100-18db-43bc-be79-9d4c95c58132"
/>

Scrolling:
<img width="801" height="81" alt="image"
src="https://github.com/user-attachments/assets/52d51c6f-747d-4d4e-bac1-d45b6607ead9"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10243-fix-App-mode-Move-active-output-spinner-items-outside-scrollable-area-3276d73d3650818186c1cbca84e53329)
by [Unito](https://www.unito.io)
2026-03-18 11:14:59 -07:00
pythongosssss
dadffa10ea fix: App mode - handle socket/response race when tracking jobs (#10244)
## Summary

Improves the handling of the job tracking where the websocket vs http
response are processed in a non-deterministic order

## Changes

- **What**: Update onJobStart watch and guard to watch both the
activeJobId and the path mapping to ensure both trigger a start, but do
not double start

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10244-fix-App-mode-handle-socket-response-race-when-tracking-jobs-3276d73d365081f2862bd6cdcce3aac7)
by [Unito](https://www.unito.io)
2026-03-18 11:03:55 -07:00
Deep Mehta
4cf42de4f9 fix: support progressive fallback for deeply nested model directories (#10196)
## Summary
- Fix `findProvidersWithFallback` to try all parent paths progressively
instead of jumping from exact match to top-level segment only.
Previously `"a/b/c"` would try `"a/b/c"` then `"a"`, now correctly tries
`"a/b/c"` → `"a/b"` → `"a"`
- Use empty key `''` for `DownloadAndLoadCogVideoControlNet` and
`DownloadAndLoadCogVideoModel` registrations so `shouldUseAssetBrowser`
returns false — these nodes use HuggingFace repo names, not file-based
assets, so the asset browser finds nothing and shows an empty dropdown

## Test plan
- [x] All 46 existing `modelToNodeStore` tests pass
- [ ] Verify CogVideo ControlNet and HF model dropdowns show options
(after backend deploy)
- [x] Verify "Use" button on CogVideo/ControlNet/* models creates the
correct node
- [ ] Verify GGUF asset browser still works

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 10:39:22 -07:00
pythongosssss
e7384ab2da feat: App mode - update keybindings (#9794)
## Summary

Improve app mode discoverability/usability

## Changes

- **What**:
- toggle linear rebound as alt+m
- toggle minimap rebound as alt+shift+m
- show keybinding for toggling mode on button

- **Breaking**:
- Toggle minimap shortcut changed from ALT+M to ALT+SHIFT+M

## Screenshots (if applicable)

<img width="302" height="145" alt="image"
src="https://github.com/user-attachments/assets/5f2df599-c0db-4df1-ba22-d7ee9eb6b662"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9794-feat-App-mode-update-keybindings-3216d73d3650811eac37f226b381e54a)
by [Unito](https://www.unito.io)
2026-03-18 08:32:36 -07:00
pythongosssss
190bbf0ac2 feat: App mode - enable mask editor (#9876)
## Summary

Adds the ability for users to access mask editor from app mode

## Changes

- **What**: 
- add mask editor button to app mode load image
- extract parseImageWidgetValue for url + test

## Screenshots (if applicable)

<img width="303" height="232" alt="image"
src="https://github.com/user-attachments/assets/89afffda-d049-4ef4-ba13-bf483222b27c"
/>
<img width="959" height="715" alt="image"
src="https://github.com/user-attachments/assets/db19a327-e53b-4047-aa66-2cb6e889be1d"
/>
<img width="308" height="302" alt="image"
src="https://github.com/user-attachments/assets/efaf601a-c2d8-4098-a3aa-1b5bbb3aec1c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9876-feat-App-mode-enable-mask-editor-3226d73d365081d5982bf5e0e870f9b3)
by [Unito](https://www.unito.io)
2026-03-18 03:08:26 -07:00
jaeone94
a3cf6fcde0 fix: convert download URLs to browsable page URLs in Copy Url button (#10228)
## Summary

- Copy Url button in MissingModelRow now copies a browsable page URL
instead of a direct download URL
- HuggingFace: `/resolve/` → `/blob/` (file page with model info and
download button)
- Civitai: strips `/api/download` or `/api/v1` prefix (model page)

## Changes

- Add `toBrowsableUrl()` to `missingModelDownload.ts` — converts
download URLs to browsable page URLs for HuggingFace and Civitai
- Update `MissingModelRow.vue` to use `toBrowsableUrl()` when copying
- Add 5 unit tests covering HuggingFace, Civitai, and non-matching URL
cases

## Test plan

- [x] Unit tests pass (14/14) — `toBrowsableUrl` covered by 5 dedicated
tests
- [x] Lint, format, typecheck pass
- [x] Manual: load workflow with missing HuggingFace models, click Copy
Url, verify copied URL opens the file page
- [x] Manual: load workflow with missing Civitai models, click Copy Url,
verify copied URL opens the model page

### Why no E2E test

The Copy Url button is only visible when `!isCloud &&
model.representative.url && !isAssetSupported`. Writing an E2E test for
the clipboard content would require:

1. A test fixture with a real HuggingFace/Civitai URL (fragile — depends
on external availability)
2. Granting `clipboard-read` permission in Playwright context
3. Ensuring `isAssetSupported` evaluates to `false` against the local
test server's node definitions

The URL transformation logic is a pure function, fully covered by unit
tests. E2E clipboard content verification has no existing patterns in
the codebase and would be environment-dependent and flaky.

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-18 13:19:52 +09:00
Jin Yi
942938d058 [refactor] Rename ResultGallery to MediaLightbox and address code review (#10235)
## Summary
Address code review feedback from #10134 by renaming the component and
improving implementation quality.

## Changes
- Rename `ResultGallery` → `MediaLightbox` across all references
- Replace `useEventListener(window, 'keydown')` with `@keydown` on
dialog element
- Remove change detector tests (`renders close button`, `prevents
default on arrow keys`)
- Remove redundant `toBeVisible()` before Playwright click (implicit
wait)
- Update keyboard tests to dispatch on dialog element instead of
`window`
- Sort button icon sizes (`icon-sm`, `icon`, `icon-lg`)
- Wire zoom event to lightbox in `MediaAssetCard` story via
`context.args`
- Add standalone `MediaLightbox` Storybook story under
`Platform/Assets/`

Fixes #10134

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10235-refactor-Rename-ResultGallery-to-MediaLightbox-and-address-code-review-3276d73d365081299b42f682373a12f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:02:08 +09:00
Deep Mehta
af7bc38e31 feat: add model-to-node mappings for LTX Video prompt enhancer (#10234)
## Summary
- Add `quickRegister()` entries for LTX Video prompt enhancer models:
- `LLM/Llama-3.2-3B-Instruct` → `LTXVPromptEnhancerLoader` (`llm_name`)
- `LLM/Florence-2-large-PromptGen-v2.0` → `LTXVPromptEnhancerLoader`
(`image_captioner_name`)

These mappings ensure the "Use" button in the model browser correctly
creates
the appropriate loader node when users click on these LLM models.

## Test plan
- [x] Verify "Use" button works for LLM/Llama-3.2-3B-Instruct in model
browser
- [x] Verify "Use" button works for LLM/Florence-2-large-PromptGen-v2.0
in model browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10234-feat-add-model-to-node-mappings-for-LTX-Video-prompt-enhancer-3276d73d36508154847cdbae8c6e9bf1)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:02:01 -07:00
Jin Yi
3c9b048974 refactor: replace PrimeVue Galleria with custom overlay in ResultGallery (#10134)
## Changes

- Replace PrimeVue `Galleria` component with a custom Tailwind-based
fullscreen overlay in `ResultGallery.vue`
- Use design system `Button` component with new `icon-lg` size variant
- Add `role="dialog"` and `aria-modal="true"` for accessibility
- Add `event.preventDefault()` on keyboard handlers
- Use VueUse `useEventListener` for auto-cleanup
- Add i18n keys for aria-labels (`g.previous`, `g.close`, `g.next`)
- Add e2e Playwright test for ResultGallery

## Testing

- 11 unit tests passing
- e2e test added for CI validation


[screen-capture.webm](https://github.com/user-attachments/assets/6401e781-9897-4586-8ed3-5251d957ce5b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10134-refactor-replace-PrimeVue-Galleria-with-custom-overlay-in-ResultGallery-3266d73d365081309042c5c2b7fbc3b0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 11:14:10 +09:00
Christian Byrne
0e5bd539ec fix: 3D asset disappears when switching to image output in app mode (#9622)
## Summary

Fix 3D asset disappearing when switching between 3D and image outputs in
app mode — missing `onUnmounted` cleanup leaked WebGL contexts.

## Changes

- **What**: Add `onUnmounted` hook to `Preview3d.vue` that calls
`viewer.cleanup()`, releasing the WebGL context when Vue destroys the
component via its v-if chain. Add unit tests covering init, cleanup on
unmount, and remount behavior.

## Review Focus

When switching outputs in app mode, Vue's v-if chain destroys and
recreates `Preview3d`. Without `onUnmounted` cleanup, the old `Load3d`
instance (WebGL context, RAF loop, ResizeObserver) leaks. After ~8-16
toggles, the browser's WebGL context limit is exhausted and new 3D
viewers silently fail to render.

<!-- Pipeline-Ticket: e36489d2-a9fb-47ca-9e27-88eb3170836b -->

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 01:08:04 +00:00
Egor
c2cf8e4fb1 perf: speed up local inference on shared GPU (#9968)
## Summary

Allows to disable UI animations to speed up local inference on shared
GPU setups. Gives 7-13% improvement

## Changes

- **What**: New setting under Appearance > General. Applies a CSS class
that disables all animations and transitions
- **Breaking**: None since it's opt in

I also considered auto-disabling animations only during generation on
shared GPU setups. We could detect that, lmk if you want it to be
implemented.

## Screenshots

Mac with Apple Silicon

<img width="362" height="369" alt="Screenshot 2026-03-16 at 01 15 03"
src="https://github.com/user-attachments/assets/aa9e3dcf-15cf-48e9-a4f5-834373bc0400"
/>
<img width="866" height="585" alt="Screenshot 2026-03-16 at 01 42 34"
src="https://github.com/user-attachments/assets/aca13c04-aa98-41bf-a5ff-4e218b9c6460"
/>

## How to reproduce:

- Run frontend & backend locally on a device with shared GPU
- Install the model: `cd models/checkpoints && curl -L -o
v1-5-pruned-emaonly-fp16.safetensors
"https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emao
  nly-fp16.safetensors"` (~2GB)
- Use this workflow: https://cloud.comfy.org/?share=4086706e0018 (also
attached):

[test-workflow.json](https://github.com/user-attachments/files/26006974/test-workflow.json)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9968-perf-speed-up-local-inference-on-shared-GPU-3246d73d365081adaa03c348a7efdc1e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 00:40:51 +00:00
Deep Mehta
5ae526dc39 fix: use server response filename in WebcamCapture serialization (#10220)
## Summary
- WebcamCapture's `serializeValue` was ignoring the `/upload/image`
response and returning the original timestamp-based filename
(`webcam/${timestamp}.png [temp]`)
- This breaks content-addressed storage backends (e.g. Comfy Cloud)
where the server returns a different (hash-based) filename, causing
`ImageDownloadError` at inference time
- Now reads `data.name` and `data.subfolder` from the upload response,
matching the pattern already used by `useNodeImageUpload` and
`useMaskEditorSaver`
- No behavioral change for local ComfyUI — the server already returns
the same filename, it was just being ignored

## Test plan
- [x] WebcamCapture node captures and uploads an image successfully on
local ComfyUI
- [x] WebcamCapture node works correctly on Comfy Cloud (no
`ImageDownloadError`)
- [ ] Duplicate filename edge case: upload two captures with same
timestamp — server-renamed file is used correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10220-fix-use-server-response-filename-in-WebcamCapture-serialization-3266d73d3650818aa9bdff8d27586180)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:47:30 -07:00
Christian Byrne
a00e4b6421 fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10203)
## Summary

`queueStore.update()` previously used a request-ID guard that discarded
responses whenever a newer call had been initiated. During concurrent
job execution, rapid WebSocket events (`status`, `execution_success`,
etc.) created a sustained stream of `update()` calls where:

1. **Every response got discarded** — each returning response saw a
higher `updateRequestId`, so data was never applied to the store until
the event burst subsided
2. **All HTTP requests still fired** — the guard didn't suppress
outgoing calls, just discarded stale responses after the fact

This caused the UI to freeze showing stale job states (e.g. completed
jobs still showing as "Running") during bursts, then snap all at once
when the storm ended.

## Changes

Replace the request-ID guard with a single-flight coalescing pattern:

- **At most one fetch in flight** at a time — no request spam
- **Every response is applied** — no UI starvation
- **Dirty flag** triggers one re-fetch if calls arrived during flight —
eventual consistency

## Testing

Added 5 unit tests covering:
- Concurrent calls coalesce into a single re-fetch
- No response starvation (every response applied)
- No duplicate requests when no concurrent calls
- Loading state transitions through coalesced re-fetches
- Normal sequential behavior preserved

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10203-fix-replace-stale-request-guard-with-single-flight-coalescing-in-queueStore-update-3266d73d365081088656e4e55ca4dbd3)
by [Unito](https://www.unito.io)
2026-03-17 14:26:59 -07:00
Terry Jia
2e5e04efd5 fix: enable 3D thumbnail support for cloud environments (#10121)
## Summary
The 3D thumbnail logic was gated behind `api.getServerFeature('assets',
false)` which only covers local servers. Use `isAssetPreviewSupported()`
to also cover cloud via `assetService.isAssetAPIEnabled()`.

follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/9471

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10121-fix-enable-3D-thumbnail-support-for-cloud-environments-3256d73d365081c18975e917c604582b)
by [Unito](https://www.unito.io)
2026-03-17 13:36:01 -07:00
Kelly Yang
55f1081874 fix: store 3d viewer config in standalone mode (#10126)
## Summary

Adds settings persistence for the standalone 3D viewer (App Mode),
ensuring custom configurations like background color and camera state
are remembered across different models.

## Changes

URL-based Caching: Added a cache to store and restore viewer settings
per model URL.
Unit Tests: Added coverage for configuration saving and restoration
workflows.


## Screenshots
before


https://github.com/user-attachments/assets/5ba0e3a1-c876-4de6-879e-e77c42661267

after


https://github.com/user-attachments/assets/debc4fbd-b5ae-484e-aa67-d4c130722ab0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10126-fix-persistence-for-3d-viewer-config-in-standalone-mode-3266d73d365081e88438ca29d4db5640)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-17 13:21:17 -07:00
Christian Byrne
f9b0f277bf fix: track nodePreviewImages in usePromotedPreviews (#10165)
## Summary

The `computed` in `usePromotedPreviews` only tracked `nodeOutputs` as a
reactive dependency. GLSL live previews (and other preview-only sources)
write to `nodePreviewImages` instead of `nodeOutputs`, so promoted
preview widgets on SubgraphNodes never re-evaluated when live previews
updated.

## Changes

**Production** (`usePromotedPreviews.ts` — 3-line fix):
- Add `nodePreviewImages[locatorId]` as a second reactive dependency
alongside `nodeOutputs[locatorId]`
- Guard now passes when *either* source has data, not just `nodeOutputs`

**Tests** (`usePromotedPreviews.test.ts`):
- Add `nodePreviewImages` to mock store type and factory
- Add `seedPreviewImages()` helper
- Add `getNodeImageUrls.mockReset()` in `beforeEach` for proper test
isolation
- Two new test cases:
- `returns preview when only nodePreviewImages exist (e.g. GLSL live
preview)`
- `recomputes when preview images are populated after first evaluation`
- Clean up existing tests to use hoisted `getNodeImageUrls` mock
directly instead of `vi.mocked(useNodeOutputStore().getNodeImageUrls)`

## What this supersedes

This is a minimal re-implementation of #9461. That PR also modified
`promotionStore.ts` with a `_version`/`_touch()` monotonic counter to
manually force reactivity — that approach is dropped here as it is an
anti-pattern (manually managing reactivity counters instead of using
Vue's built-in reactivity system). The promotionStore changes were not
needed for this fix.

## Related

- Supersedes #9461
- Prerequisite work: #9198 (add GLSLShader to canvas image preview node
types)
- Upstream feature: #9201 (useGLSLPreview composable)
- Adjacent: #9435 (centralize node image rendering state in
NodeImageStore)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10165-fix-track-nodePreviewImages-in-usePromotedPreviews-for-GLSL-live-preview-propagation-3266d73d365081cd87d0d12c4c041907)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 12:54:21 -07:00
Alexander Brown
34a77e5016 test: harden subgraph test coverage and remove low-value tests (#9967)
## Summary

Harden subgraph test coverage: remove low-value change-detector tests,
consolidate fixtures, add behavioral coverage, and fix test
infrastructure issues. Includes minor production code corrections
discovered during test hardening.

## Changes

- **What**: Comprehensive subgraph test suite overhaul across 6 phases
  - Removed change-detector tests and redundant assertions
- Consolidated fixture helpers into `subgraphHelpers.ts` /
`subgraphFixtures.ts`
  - Added Pinia initialization and fixture reset to all test files
  - Fixed barrel import violations (circular dependency prevention)
  - Added behavioral coverage for slot connections, events, edge cases
  - Added E2E helper and smoke test for subgraph promotion
  - Exported `SubgraphSlotBase` from litegraph barrel for test access
- **Production code changes** (minor correctness fixes found during
testing):
- `resolveSubgraphInputLink.ts`: iterate forward (first-connected-wins)
to match `_resolveLinkedPromotionBySubgraphInput`
- `promotionSchema.ts`: return `[]` instead of throwing on invalid
`proxyWidgets`; console.warn always (not DEV-only)
  - `LGraph.ts`: disconnect-after-veto ordering fix
  - `litegraph.ts`: barrel export swap for `SubgraphSlotBase`
- **Stats**: 349 tests passing, 0 skipped across 26 test files

## Review Focus

- Tests that merely asserted default property values were deleted
(change detectors)
- Fixture state is now reset via `resetSubgraphFixtureState()` in
`beforeEach`
- All imports use `@/lib/litegraph/src/litegraph` barrel to avoid
circular deps
- Production changes are small and directly motivated by test findings

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-17 19:03:18 +00:00
Alexander Brown
b696b2f2e1 feat: migrate TS lint rules from ESLint to oxlint (#10114)
## Summary

Migrate two TypeScript lint rules from ESLint to oxlint and upgrade
`eslint-plugin-oxlint` to 1.55.0.

## Changes

- **Rule migration**: Move `typescript/no-import-type-side-effects` and
`typescript/no-empty-object-type` (with `allowInterfaces: 'always'`)
from `eslint.config.ts` to `.oxlintrc.json`.
- **Plugin upgrade**: Bump `eslint-plugin-oxlint` from 1.25.0 to 1.55.0,
which auto-disables additional ESLint rules now covered by oxlint. This
surfaced pre-existing `no-unused-expressions` violations (the `void`
prefixes and ternary-to-if/else changes), which are fixed in a separate
commit.
- **Dependencies**: None — `eslint-plugin-oxlint` auto-disables the
corresponding ESLint rules when they appear in `.oxlintrc.json`.

## Review Focus

- Verify the two migrated rules produce identical diagnostics in oxlint
as they did in ESLint

## Migration Status

See `temp/plans/eslint-to-oxlint-migration.md` for what can/can't
migrate and why.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 11:38:48 -07:00
jaeone94
15442e7ff8 fix: prevent nested SubgraphNode input slots from doubling on reload (#10187)
## Summary

- Fix nested SubgraphNode input slots doubling on each page reload
- Root cause: during configure, `_configureSubgraph` recreates
`SubgraphInput` objects with new references, and the `input-added` event
handler used `===` identity check which failed for these new objects,
causing `addInput()` to duplicate inputs
- Add `id`-based fallback matching in the `input-added` handler and
rebind `_subgraphSlot` with re-registered listeners

## Changes

**`SubgraphNode.ts:614-622`**: Add UUID `id` fallback to the `===`
reference check in the `input-added` event handler. When a stale
reference is matched by id, call `_addSubgraphInputListeners()` to
update `_subgraphSlot` and re-register listeners on the new
`SubgraphInput` object.

**`SubgraphNode.test.ts`**: 2 regression tests for nested subgraph
reconfigure scenarios.

## Test plan

- [x] Existing SubgraphNode tests pass (6 passed, 34 skipped)
- [x] New tests verify inputs don't duplicate after single and repeated
reconfigure cycles
- [x] Manual: create a subgraph containing another subgraph node, save,
reload — input slots should remain unchanged

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10187-fix-prevent-nested-SubgraphNode-input-slots-from-doubling-on-reload-3266d73d3650817286abea52365a626e)
by [Unito](https://www.unito.io)
2026-03-18 03:23:05 +09:00
jaeone94
6a8e6ad254 test: update E2E to expect missing nodes overlay on tab switch (#10190)
## Summary

- Update the E2E test expectation for missing nodes overlay behavior
when switching between opened workflows
- `showMissingNodes` was intentionally changed to `true` in
`workflowService.ts` so users always see missing node pack info when
switching tabs, allowing them to install missing packs without a page
reload
- The test previously asserted the overlay should NOT reappear; now it
asserts the overlay SHOULD reappear

## Test plan

- [x] CI E2E tests pass with updated expectation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10190-test-update-E2E-to-expect-missing-nodes-overlay-on-tab-switch-3266d73d36508106a48efff7309bd4e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 10:46:19 -07:00
Christian Byrne
a405a992af ci: re generate outdated test snapshot baseline (#10189)
Empty commit to trigger CI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10189-ci-re-generate-outdated-test-snapshot-baseline-3266d73d365081dfa053e30bdd04647a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 10:05:23 -07:00
Christian Byrne
2af3940867 feat: add trend visualization with sparklines to perf report (#9939)
## Summary

Add historical trend visualization (ASCII sparklines + directional
arrows) to the performance PR report, showing how each metric has moved
over recent commits on main.

## Changes

- **What**: New `sparkline()`, `trendDirection()`, `trendArrow()`
functions in `perf-stats.ts`. New collapsible "Trend" section in the
perf report showing per-metric sparklines, direction indicators, and
latest values. CI workflow updated to download historical data from the
`perf-data` orphan branch and switched to `setup-frontend` action with
`pnpm exec tsx`.

## Review Focus

- The trend section only renders when ≥3 historical data points exist
(gracefully absent otherwise)
- `trendDirection()` uses a split-half mean comparison with ±10%
threshold — review whether this sensitivity is appropriate
- The `git archive` step in `pr-perf-report.yaml` is idempotent and
fails silently if no perf-history data exists yet on the perf-data
branch

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9939-feat-add-trend-visualization-with-sparklines-to-perf-report-3246d73d36508125a6fcc39612f850fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:10:30 -07:00
Christian Byrne
48ae70159f feat: migrate 13 priority events from Mixpanel-only to GA4 via GTM (#9770)
## Summary

Migrate all client-side telemetry events to GA4 via the
GtmTelemetryProvider, completing the dual-write setup alongside
Mixpanel.

## Changes

- Add all client-side Mixpanel events to GtmTelemetryProvider for GA4
dual-write
- Map events to GA4 recommended names where applicable (search,
select_item, login, sign_up)
- Exclude server-side payment events (subscription success/cancel,
credit topup succeeded) - those belong in comfy-api via Measurement
Protocol
- Truncate error strings to 100 chars to respect GA4 parameter limits
- Keep event parameter counts under GA4 25-param limit

## Events Added

### GA4 Recommended Events
- sign_up / login (auth flow)
- begin_checkout (checkout flow)
- search (node search)
- select_item (node search result selected)
- select_content (template opened)
- view_promotion / select_promotion (subscription modal)

### Custom Events
- Survey, email verification, workflow lifecycle
(opened/imported/created/saved)
- Execution lifecycle (start/error/success)
- Template library, help center, settings, UI interactions
- App mode, share flow, page visibility, tab count

### Excluded (Server-Side Only)
- subscription_success -> comfy-api Stripe webhook
- subscription_cancel -> comfy-api Stripe webhook
- credit_topup_succeeded -> comfy-api Stripe webhook

## Testing
- Unit tests cover initialization, event dispatch, GA4 event name
mapping, and parameter handling
- Verified typecheck and lint pass

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:03:57 -07:00
Jukka Seppänen
0030cadba3 feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10111)
## Summary

Enable virtual nodes (e.g. Set/Get) to resolve their output source
directly when the source lives in a different subgraph.

## Changes

- **What**: Added optional resolveVirtualOutput method on LGraphNode and
a new resolution path in ExecutableNodeDTO.resolveOutput that checks it
before falling through to the existing getInputLink path. Includes unit
tests for the three code paths (happy path, missing DTO, fallthrough).

## Review Focus

- Fully backwards compatible — no existing node implements
resolveVirtualOutput, so the new path is always skipped for current
virtual nodes (Reroute, PrimitiveNode, etc.).

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

## Screenshots

Simple example in actual use, combined with new changes in KJNodes
allows using Get nodes inside subgraphs:

<img width="2242" height="1434" alt="image"
src="https://github.com/user-attachments/assets/cc940a95-e0bb-4adf-91b6-9adc43a74aa2"
/>

<img width="1436" height="440" alt="image"
src="https://github.com/user-attachments/assets/62044af5-0d6e-4c4e-b34c-d33e85f2b969"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10111-feat-resolveVirtualOutput-for-cross-subgraph-virtual-nodes-eg-Set-Get-3256d73d3650816a9f20e28029561c58)
by [Unito](https://www.unito.io)
2026-03-17 06:58:40 -07:00
Christian Byrne
60e8da308f fix: resolve nodes in subgraphs for image copy/paste and display (#10009)
## Summary

Replace `app.rootGraph.getNodeById()` with `resolveNode()` so node
lookups search subgraphs, fixing broken image copy/paste and display for
nodes inside subgraphs.

## Changes

- **What**: Updated 7 call sites across 5 files to use `resolveNode()`
from `litegraphUtil.ts` instead of `app.rootGraph.getNodeById()`. The
`resolveNode` function already existed and searches both the root graph
and all subgraphs. Added a unit test verifying subgraph node resolution
in `nodeOutputStore`.

## Review Focus

The fix is mechanical — each call site simply swaps
`app.rootGraph?.getNodeById(id)` for `resolveNode(id)`. The
`resolveNode` utility iterates `graph.subgraphs` if the node is not
found in the root graph.

Fixes #9993

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10009-fix-resolve-nodes-in-subgraphs-for-image-copy-paste-and-display-3256d73d3650814f9467c53999f5d755)
by [Unito](https://www.unito.io)
2026-03-17 06:58:01 -07:00
Christian Byrne
1ee75332b3 fix(vueNodes): decrease default size of reroute nodes (#8734)
## Summary

Remove the 225px minimum width constraint from reroute nodes so they
render at their intended ~75×26px size.

## Changes

- **What**: Reroute nodes now bypass the `min-w-[225px]` CSS constraint
and bottom padding applied to regular nodes. `resizable: false` is set
on the RerouteNode constructor to hide the resize handle. An
`isRerouteNode` computed in `LGraphNode.vue` gates these behaviors by
checking `nodeData.type === "Reroute"`.

## Review Focus

- Detection mechanism uses `type === "Reroute"` (explicit) rather than
`titleMode === NO_TITLE` (semantic but too broad). See PR #8574 as prior
art for reroute-specific conditionals.
- PR #7993 (open) modifies `LGraphNode.ts` pos/size setters but does not
touch `LGraphNode.vue` template or resize callback — no conflict
expected.

Fixes #4704

## Screenshots (if applicable)

Reroute nodes now render at ~75px wide instead of being forced to 225px
minimum.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8734-fix-vueNodes-decrease-default-size-of-reroute-nodes-3016d73d3650816dbeead93517a52f25)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 06:27:13 -07:00
497 changed files with 25419 additions and 6676 deletions

View File

@@ -173,6 +173,19 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download perf history from perf-data branch
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
continue-on-error: true
run: |
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
fi
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >

View File

@@ -30,6 +30,7 @@ concurrency:
jobs:
bump-version:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -18,6 +18,7 @@ concurrency:
jobs:
docs-check:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:

View File

@@ -97,6 +97,13 @@
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

@@ -51,6 +51,9 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
export class MaintenanceTaskRunner {
class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState

View File

@@ -0,0 +1,169 @@
{
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"pos": [400, 300],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 120,
"lastLinkId": 276,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Slot Drift Duplicate Links",
"inputNode": {
"id": -10,
"bounding": [0, 300, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 300, 120, 60]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 120,
"type": "ComfySwitchNode",
"title": "Switch (CFG)",
"pos": [100, 100],
"size": [200, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [257, 271, 276]
}
],
"properties": { "Node name for S&R": "ComfySwitchNode" },
"widgets_values": []
},
{
"id": 85,
"type": "KSamplerAdvanced",
"pos": [400, 50],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 276 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
},
{
"id": 86,
"type": "KSamplerAdvanced",
"pos": [400, 350],
"size": [270, 262],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null },
{ "name": "steps", "type": "INT", "link": null },
{ "name": "cfg", "type": "FLOAT", "link": 271 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": { "Node name for S&R": "KSamplerAdvanced" },
"widgets_values": [
false,
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
false
]
}
],
"groups": [],
"links": [
{
"id": 257,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 271,
"origin_id": 120,
"origin_slot": 0,
"target_id": 86,
"target_slot": 5,
"type": "FLOAT"
},
{
"id": 276,
"origin_id": 120,
"origin_slot": 0,
"target_id": 85,
"target_slot": 5,
"type": "FLOAT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "scale": 1, "offset": [0, 0] },
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -0,0 +1,172 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -0,0 +1,407 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -33,6 +33,7 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
@@ -176,6 +177,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -221,12 +223,13 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)
this.settings = new SettingsHelper(page)
this.keyboard = new KeyboardHelper(page, this.canvas)
this.clipboard = new ClipboardHelper(this.keyboard)
this.clipboard = new ClipboardHelper(this.keyboard, page)
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
@@ -287,9 +290,7 @@ export class ComfyPage {
clearStorage?: boolean
mockReleases?: boolean
} = {}) {
await this.goto()
// Mock release endpoint to prevent changelog popups
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
await this.page.route('**/releases**', async (route) => {
const url = route.request().url()
@@ -309,12 +310,16 @@ export class ComfyPage {
}
if (clearStorage) {
// Navigate to a lightweight same-origin endpoint to obtain a page
// context for clearing storage without loading the full frontend app.
await this.page.goto(`${this.url}/api/users`)
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
}
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)

View File

@@ -0,0 +1,201 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.nextFrame()
}
/**
* Inject linearData into the current graph and enter app mode.
*
* Serializes the graph, injects linearData with the given inputs and
* auto-detected output node IDs, then reloads so the appModeStore
* picks up the data via its activeWorkflow watcher.
*
* @param inputs - Widget selections as [nodeId, widgetName] tuples
*/
async enterAppModeWithInputs(inputs: [string, string][]) {
await this.page.evaluate(async (inputTuples) => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
}, inputs)
await this.comfyPage.nextFrame()
await this.toggleAppMode()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/**
* 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").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -91,6 +91,12 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -1,9 +1,16 @@
import type { Locator } from '@playwright/test'
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
import { getMimeType } from './mimeTypeUtil'
export class ClipboardHelper {
constructor(private readonly keyboard: KeyboardHelper) {}
constructor(
private readonly keyboard: KeyboardHelper,
private readonly page: Page
) {}
async copy(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyC', locator ?? null)
@@ -12,4 +19,44 @@ export class ClipboardHelper {
async paste(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -3,6 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
export class DragDropHelper {
constructor(
@@ -48,19 +49,8 @@ export class DragDropHelper {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}

View File

@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -6,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -322,4 +324,93 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -0,0 +1,13 @@
export function getMimeType(fileName: string): string {
const name = fileName.toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.svg')) return 'image/svg+xml'
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -28,9 +28,18 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
},
topbar: {
queueButton: 'queue-button',
@@ -58,6 +67,11 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -67,6 +81,10 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -78,15 +96,18 @@ export type TestIdValue =
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -281,6 +281,14 @@ export class NodeReference {
getType(): Promise<string> {
return this.getProperty('type')
}
async centerOnNode(): Promise<void> {
await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
window.app!.canvas.centerOnNode(node)
}, this.id)
await this.comfyPage.nextFrame()
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')

View File

@@ -29,7 +29,8 @@ export const webSocketFixture = base.extend<{
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.toString())
const u = new URL(window.location.href)
u.hash = ''
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'

View File

@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -48,6 +75,26 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -14,3 +16,30 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -0,0 +1,168 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
* All widgets from the default graph are selected so the panel scrolls,
* pushing the last widget's dropdown to the clipping boundary.
*/
const DEFAULT_INPUTS: [string, string][] = [
['4', 'ckpt_name'],
['6', 'text'],
['7', 'text'],
['5', 'width'],
['5', 'height'],
['5', 'batch_size'],
['3', 'seed'],
['3', 'steps'],
['3', 'cfg'],
['3', 'sampler_name'],
['3', 'scheduler'],
['3', 'denoise'],
['9', 'filename_prefix']
]
function isClippedByAnyAncestor(el: Element): boolean {
const child = el.getBoundingClientRect()
let parent = el.parentElement
while (parent) {
const overflow = getComputedStyle(parent).overflow
if (overflow !== 'visible') {
const p = parent.getBoundingClientRect()
if (
child.top < p.top ||
child.bottom > p.bottom ||
child.left < p.left ||
child.right > p.right
) {
return true
}
}
parent = parent.parentElement
}
return false
}
/** Add a node to the graph by type and return its ID. */
async function addNode(page: Page, nodeType: string): Promise<string> {
return page.evaluate((type) => {
const node = window.app!.graph.add(
window.LiteGraph!.createNode(type, undefined, {})
)
return String(node!.id)
}, nodeType)
}
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Select dropdown is not clipped in app mode panel', async ({
comfyPage
}) => {
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[saveVideoId, 'codec']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 })
const isInViewport = await overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
test('FormDropdown popup is not clipped in app mode panel', async ({
comfyPage
}) => {
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[loadImageId, 'image']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -0,0 +1,187 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Rename from builder input-select sidebar via menu', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
})
test('Rename from builder input-select sidebar via double-click', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToInputs()
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

@@ -38,16 +38,13 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#222222',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',
@@ -102,16 +99,13 @@ const customColorPalettes = {
CLEAR_BACKGROUND_COLOR: '#000',
NODE_TITLE_COLOR: 'rgba(255,255,255,.75)',
NODE_SELECTED_TITLE_COLOR: '#FFF',
NODE_TEXT_SIZE: 14,
NODE_TEXT_COLOR: '#b8b8b8',
NODE_SUBTEXT_SIZE: 12,
NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)',
NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)',
NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)',
NODE_DEFAULT_SHAPE: 'box',
NODE_BOX_OUTLINE_COLOR: '#236692',
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)',
DEFAULT_GROUP_FONT: 24,
WIDGET_BGCOLOR: '#242424',
WIDGET_OUTLINE_COLOR: '#333',
WIDGET_TEXT_COLOR: '#a3a3a8',

View File

@@ -129,4 +129,74 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
})
test(
'Copy paste node, image paste onto LoadImage, image paste on empty canvas',
{ tag: ['@node'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
await comfyPage.clipboard.paste()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(3)
// Step 2: Paste image onto selected LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
await comfyPage.clipboard.pasteFile(
comfyPage.assetPath('image32x32.webp')
)
await uploadPromise
await expect
.poll(
async () => {
const fileWidget = await loadImageNodes[0].getWidget(0)
return fileWidget.getValue()
},
{ timeout: 5_000 }
)
.toContain('image32x32')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
const uploadPromise2 = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
await comfyPage.clipboard.pasteFile(
comfyPage.assetPath('image32x32.webp')
)
await uploadPromise2
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(4)
const allLoadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(allLoadImageNodes).toHaveLength(2)
}
)
})

View File

@@ -0,0 +1,318 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,11 +42,13 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -75,7 +77,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -144,6 +148,44 @@ test.describe('Execution error', () => {
})
})
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for error overlay and click "See Errors"
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
await expect(copyButton).toBeVisible()
})
})
test.describe('Missing models in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -168,7 +210,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -184,7 +226,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -195,13 +237,10 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -23,4 +23,85 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
})
// Regression: duplicate links with shifted target_slot (widget-to-input
// conversion) caused the wrong link to survive during deduplication.
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraph = graph.subgraphs.values().next().value
if (!subgraph) return { error: 'No subgraph found' }
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
const switchCfg = subgraph.getNodeById(120)
const ksampler85 = subgraph.getNodeById(85)
const ksampler86 = subgraph.getNodeById(86)
if (!switchCfg || !ksampler85 || !ksampler86)
return { error: 'Required nodes not found' }
// Find cfg inputs by name (slot indices shift due to widget-to-input)
const cfgInput85 = ksampler85.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfgInput86 = ksampler86.inputs.find(
(i: { name: string }) => i.name === 'cfg'
)
const cfg85Linked = cfgInput85?.link != null
const cfg86Linked = cfgInput86?.link != null
// Verify the surviving links exist in the subgraph link map
const cfg85LinkValid =
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
const cfg86LinkValid =
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
let cfgLinkToNode85Count = 0
for (const link of subgraph.links.values()) {
if (link.origin_id === 120 && link.target_id === 85)
cfgLinkToNode85Count++
}
return {
cfg85Linked,
cfg86Linked,
cfg85LinkValid,
cfg86LinkValid,
cfg85LinkId: cfgInput85?.link ?? null,
cfg86LinkId: cfgInput86?.link ?? null,
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
switchOutputLinkCount,
cfgLinkToNode85Count
}
})
expect(result).not.toHaveProperty('error')
// Both KSamplerAdvanced nodes must have their cfg input connected
expect(result.cfg85Linked).toBe(true)
expect(result.cfg86Linked).toBe(true)
// Links must exist in the subgraph link map
expect(result.cfg85LinkValid).toBe(true)
expect(result.cfg86LinkValid).toBe(true)
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
expect(result.switchOutputLinkCount).toBe(2)
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
expect(result.cfgLinkToNode85Count).toBe(1)
// Output link IDs must match the input link IDs (source/target integrity)
expect(result.switchOutputLinkIds).toEqual(
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
)
})
})

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,257 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
const TEST_PRESET = {
name: 'test-preset',
newBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true, shift: true },
targetElementId: 'graph-canvas-container'
}
],
unsetBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true },
targetElementId: 'graph-canvas-container'
}
]
}
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
const menuButton = page.getByTestId('keybinding-preset-menu')
await menuButton.click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('menuitem', { name: /Import preset/i }).click()
const fileChooser = await fileChooserPromise
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
fs.writeFileSync(presetPath, JSON.stringify(preset))
await fileChooser.setFiles(presetPath)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
{ method: 'DELETE' }
)
await comfyPage.settings.setSetting(
'Comfy.Keybinding.CurrentPreset',
'default'
)
})
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
comfyPage
}) => {
test.setTimeout(30000)
const { page } = comfyPage
// Verify default Ctrl+A select-all works
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss, then close settings via Escape
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Load workflow again, use new keybind Ctrl+Shift+A
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+Shift+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBeGreaterThan(0)
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to default preset
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await presetTrigger.click()
await page.getByRole('option', { name: /Default Preset/i }).click()
// Handle unsaved changes dialog if the preset was marked as modified
const discardButton = page.getByRole('button', {
name: /Discard and Switch/i
})
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await discardButton.click()
}
await expect(presetTrigger).toContainText('Default Preset')
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can export a preset and re-import it', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Export via ellipsis menu
await menuButton.click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: /Export preset/i }).click()
const download = await downloadPromise
// Verify filename contains test-preset
expect(download.suggestedFilename()).toContain('test-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Verify the downloaded file is valid JSON with correct structure
const downloadPath = await download.path()
expect(downloadPath).toBeTruthy()
const content = fs.readFileSync(downloadPath!, 'utf-8')
const parsed = JSON.parse(content) as {
name: string
newBindings: unknown[]
unsetBindings: unknown[]
}
expect(parsed).toHaveProperty('name')
expect(parsed).toHaveProperty('newBindings')
expect(parsed).toHaveProperty('unsetBindings')
expect(parsed.name).toBe('test-preset')
})
test('Can delete an imported preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Delete via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
// Confirm deletion in the dialog
const confirmDialog = page.getByRole('dialog', {
name: /Delete the current preset/i
})
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
// Verify preset trigger now shows Default Preset
await expect(presetTrigger).toContainText('Default Preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can save modifications as a new preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Save as new preset via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
// Fill in the preset name in the prompt dialog
const promptInput = page.locator('.prompt-dialog-content input')
await promptInput.fill('my-custom-preset')
await promptInput.press('Enter')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Verify preset trigger shows my-custom-preset
await expect(presetTrigger).toContainText('my-custom-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Cleanup: delete the my-custom-preset file
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
{ method: 'DELETE' }
)
})
})

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
@@ -11,58 +9,10 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
@@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
@@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Workflow info section visible', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
@@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Returns to graph mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await enterGraphMode(comfyPage)
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
@@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')

View File

@@ -24,6 +24,20 @@ test.describe(
)
})
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(minimapButton).toBeVisible()
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-graph-canvas-toolbar.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -79,6 +79,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)

View File

@@ -0,0 +1,70 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
async function runAndOpenGallery(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
// Wait for SaveImage node to produce output
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30_000
})
// Open Assets sidebar tab and wait for it to load
await comfyPage.page.locator('.assets-tab-button').click()
await comfyPage.page
.locator('.sidebar-content-container')
.waitFor({ state: 'visible' })
// Wait for any asset card to appear (may contain img or video)
const assetCard = comfyPage.page
.locator('[role="button"]')
.filter({ has: comfyPage.page.locator('img, video') })
.first()
await expect(assetCard).toBeVisible({ timeout: 30_000 })
// Hover to reveal zoom button, then click it
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).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()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -123,6 +123,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
test('Can pin and unpin', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -232,7 +232,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Does not report warning when switching between opened workflows', async ({
test('Reports missing nodes warning again when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -254,10 +254,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should not reappear
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -631,6 +632,51 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
test.describe('DOM Widget Promotion', () => {
@@ -744,11 +790,9 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.getByRole('link', {
// In the subgraph navigation breadcrumbs, the home/top level
// breadcrumb is just the workflow name without the folder path
name: 'subgraph-with-promoted-text-widget'
})
const homeBreadcrumb = comfyPage.page.locator(
'.p-breadcrumb-list > :first-child'
)
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()

View File

@@ -0,0 +1,117 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -0,0 +1,347 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
for (const exists of results) {
expect(exists).toBe(true)
}
}
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
}
)

View File

@@ -0,0 +1,110 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('No link found') ||
text.includes('Failed to resolve legacy -1') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
}
})
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})

View File

@@ -0,0 +1,141 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -73,5 +73,59 @@ test.describe(
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId!)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
@@ -12,25 +11,6 @@ import {
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
if (!canvas.graph) return
canvas.setGraph(canvas.graph.rootGraph)
})
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -190,7 +170,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -262,7 +242,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -296,7 +276,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -342,7 +322,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -377,7 +357,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -408,7 +388,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -564,6 +544,30 @@ test.describe(
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
@@ -721,6 +725,44 @@ test.describe(
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
@@ -743,15 +785,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')

View File

@@ -0,0 +1,132 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
interface NodeSlotData {
nodeId: string
isSubgraph: boolean
nodeW: number
nodeH: number
slots: SlotMeasurement[]
}
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe(
'Subgraph slot alignment after LG layout scale',
{ tag: ['@subgraph', '@canvas'] },
() => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
const SLOT_BOUNDS_MARGIN = 20
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const workflowPath = resolve(
import.meta.dirname,
'../assets/subgraphs/basic-subgraph.json'
)
const workflow = JSON.parse(
readFileSync(workflowPath, 'utf-8')
) as ComfyWorkflowJSON
workflow.extra = {
...workflow.extra,
workflowRendererVersion: 'LG'
}
await comfyPage.page.evaluate(
(wf) =>
window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, {
openSource: 'template'
}),
workflow
)
await comfyPage.nextFrame()
// Wait for slot elements to appear in DOM
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph._nodes
const slotData: NodeSlotData[] = []
for (const node of nodes) {
const nodeId = String(node.id)
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) continue
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
if (slotEls.length === 0) continue
const slots: SlotMeasurement[] = []
const nodeRect = nodeEl.getBoundingClientRect()
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
slots.push({
key: slotKey,
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
})
}
slotData.push({
nodeId,
isSubgraph: !!node.isSubgraphNode?.(),
nodeW: nodeRect.width,
nodeH: nodeRect.height,
slots
})
}
return slotData
})
const subgraphNodes = result.filter((n) => n.isSubgraph)
expect(subgraphNodes.length).toBeGreaterThan(0)
for (const node of subgraphNodes) {
for (const slot of node.slots) {
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetX,
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
expect(
slot.offsetY,
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
}
}
})
}
)

View File

@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
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/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -2,9 +2,116 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../fixtures/ComfyPage'
const CREATE_GROUP_HOTKEY = 'Control+g'
type NodeGroupCenteringError = {
horizontal: number
vertical: number
}
type NodeGroupCenteringErrors = {
innerGroup: NodeGroupCenteringError
outerGroup: NodeGroupCenteringError
}
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
innerGroup: {
horizontal: 16.308832840862777,
vertical: 17.390899314547084
},
outerGroup: {
horizontal: 20.30164329441476,
vertical: 42.196324096481476
}
} as const
const CENTERING_TOLERANCE = {
innerGroup: 6,
outerGroup: 12
} as const
function expectWithinBaseline(
actual: number,
baseline: number,
tolerance: number
) {
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
}
async function getNodeGroupCenteringErrors(
comfyPage: ComfyPage
): Promise<NodeGroupCenteringErrors> {
return comfyPage.page.evaluate(() => {
type GraphNode = {
id: number | string
pos: ReadonlyArray<number>
}
type GraphGroup = {
title: string
pos: ReadonlyArray<number>
size: ReadonlyArray<number>
}
const app = window.app!
const node = app.graph.nodes[0] as GraphNode | undefined
if (!node) {
throw new Error('Expected a node in the loaded workflow')
}
const nodeElement = document.querySelector<HTMLElement>(
`[data-node-id="${node.id}"]`
)
if (!nodeElement) {
throw new Error(`Vue node element not found for node ${node.id}`)
}
const groups = app.graph.groups as GraphGroup[]
const innerGroup = groups.find((group) => group.title === 'Inner Group')
const outerGroup = groups.find((group) => group.title === 'Outer Group')
if (!innerGroup || !outerGroup) {
throw new Error('Expected both Inner Group and Outer Group in graph')
}
const nodeRect = nodeElement.getBoundingClientRect()
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
group.pos[0],
group.pos[1]
])
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
group.pos[0] + group.size[0],
group.pos[1] + group.size[1]
])
const groupLeft = Math.min(groupStartX, groupEndX)
const groupRight = Math.max(groupStartX, groupEndX)
const groupTop = Math.min(groupStartY, groupEndY)
const groupBottom = Math.max(groupStartY, groupEndY)
const leftGap = nodeRect.left - groupLeft
const rightGap = groupRight - nodeRect.right
const topGap = nodeRect.top - groupTop
const bottomGap = groupBottom - nodeRect.bottom
return {
horizontal: Math.abs(leftGap - rightGap),
vertical: Math.abs(topGap - bottomGap)
}
}
return {
innerGroup: getCenteringError(innerGroup),
outerGroup: getCenteringError(outerGroup)
}
})
}
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -74,4 +181,45 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.vueNodes.waitForNodes(1)
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
const extra = window.app!.graph.extra as
| { workflowRendererVersion?: string }
| undefined
return extra?.workflowRendererVersion
})
expect(workflowRendererVersion).toMatch(/^Vue/)
await expect(async () => {
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
expectWithinBaseline(
centeringErrors.innerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.innerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
CENTERING_TOLERANCE.outerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
CENTERING_TOLERANCE.outerGroup
)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -0,0 +1,24 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.workflow.loadWorkflow('links/single_connected_reroute_node')
await comfyPage.vueNodes.waitForNodes()
})
test(
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-reroute-node-compact.png'
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.toBeGreaterThan(0)
})
})

View File

@@ -46,4 +46,16 @@ test.describe('Vue Multiline String Widget', () => {
await expect(textarea).toHaveValue('Keep me around')
})
test('should use native context menu when focused', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)
const vueContextMenu = comfyPage.page.locator('.p-contextmenu')
await textarea.focus()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).not.toBeVisible()
await textarea.blur()
await textarea.click({ button: 'right' })
await expect(vueContextMenu).toBeVisible()
})
})

View File

@@ -0,0 +1,71 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,13 +8,15 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
## Creating a New ADR

View File

@@ -5,6 +5,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
import testingLibrary from 'eslint-plugin-testing-library'
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
@@ -152,13 +153,6 @@ export default defineConfig([
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
@@ -278,6 +272,20 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
plugins: { 'testing-library': testingLibrary },
rules: {
'testing-library/prefer-screen-queries': 'error',
'testing-library/no-container': 'error',
'testing-library/no-node-access': 'error',
'testing-library/no-wait-for-multiple-assertions': 'error',
'testing-library/prefer-find-by': 'error',
'testing-library/prefer-presence-queries': 'error',
'testing-library/prefer-user-event': 'error',
'testing-library/no-debugging-utils': 'error'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {

View File

@@ -6,7 +6,6 @@ const config: KnipConfig = {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
@@ -14,25 +13,23 @@ const config: KnipConfig = {
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
entry: ['src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
project: ['src/**/*.{js,ts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
project: ['src/**/*.{js,ts}']
}
},
ignoreBinaries: ['python3', 'gh', 'generate'],
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
@@ -40,19 +37,12 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
'@primevue/icons'
],
ignore: [
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'packages/ingest-types/src/types.gen.ts',
'packages/ingest-types/src/zod.gen.ts',
'packages/ingest-types/openapi-ts.config.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
@@ -60,17 +50,8 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
'.agents/checks/eslint.strict.config.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
.join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},

View File

@@ -11,7 +11,7 @@ export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
const hasBrowserTestsChanges = stagedFiles

View File

@@ -36,5 +36,6 @@
"targetName": "e2e"
}
}
]
],
"analytics": false
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.2",
"version": "1.43.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -81,6 +81,7 @@
"@tiptap/starter-kit": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
@@ -134,6 +135,9 @@
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/vue": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -152,6 +156,7 @@
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fast-check": "catalog:",
@@ -176,9 +181,7 @@
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-primeui": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",

View File

@@ -12,7 +12,9 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/utils": "catalog:"
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -15,7 +15,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}]");
@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}]");
/* 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}]");
@@ -1901,3 +1901,37 @@ audio.comfy-audio.empty-audio-widget {
background-position: 0 0;
}
}
@utility scroll-shadows-* {
overflow: auto;
background:
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
transparent
)
center top,
/* Shadow BOTTOM */
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
transparent
)
center bottom;
background-repeat: no-repeat;
background-size:
300% 40px,
300% 40px,
300% 14px,
300% 14px;
background-attachment: local, local, scroll, scroll;
}

View File

@@ -0,0 +1,3 @@
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -631,3 +631,10 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
mediaType === '3D'
)
}
export function formatTime(seconds: number): string {
if (isNaN(seconds) || seconds === 0) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}

1505
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -34,6 +34,9 @@ catalog:
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
'@testing-library/vue': ^8.1.0
'@tiptap/core': ^2.27.2
'@tiptap/extension-link': ^2.27.2
'@tiptap/extension-table': ^2.27.2
@@ -65,8 +68,9 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-oxlint: 1.55.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
@@ -79,14 +83,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^5.75.1
knip: ^6.0.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.5.2
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -7,6 +7,9 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -73,8 +76,11 @@ function groupByName(
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
for (const entry of readdirSync(HISTORY_DIR)) {
const entryPath = join(HISTORY_DIR, entry)
const filePath = entry.endsWith('.json')
? entryPath
: join(entryPath, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
@@ -102,6 +108,27 @@ function getHistoricalStats(
return computeStats(values)
}
function getHistoricalTimeSeries(
reports: PerfReport[],
testName: string,
metric: MetricKey
): number[] {
const sorted = [...reports].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
const values: number[] = []
for (const r of sorted) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
values.push(
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
)
}
}
return values
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
@@ -233,6 +260,34 @@ function renderFullReport(
}
lines.push('', '</details>')
const trendRows: string[] = []
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const series = getHistoricalTimeSeries(historical, testName, key)
if (series.length < 3) continue
const dir = trendDirection(series)
const arrow = trendArrow(dir)
const spark = sparkline(series)
const last = series[series.length - 1]
trendRows.push(
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
)
}
}
if (trendRows.length > 0) {
lines.push(
'',
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
'',
'| Metric | Trend | Dir | Latest |',
'|--------|-------|-----|--------|',
...trendRows,
'',
'</details>'
)
}
return lines
}

View File

@@ -5,6 +5,9 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -131,3 +134,68 @@ describe('isNoteworthy', () => {
expect(isNoteworthy('noisy')).toBe(false)
})
})
describe('sparkline', () => {
it('returns empty string for no values', () => {
expect(sparkline([])).toBe('')
})
it('returns mid-height for single value', () => {
expect(sparkline([50])).toBe('▄')
})
it('renders ascending values low to high', () => {
const result = sparkline([0, 25, 50, 75, 100])
expect(result).toBe('▁▃▅▆█')
})
it('renders identical values as flat line', () => {
const result = sparkline([10, 10, 10])
expect(result).toBe('▄▄▄')
})
it('renders descending values high to low', () => {
const result = sparkline([100, 50, 0])
expect(result).toBe('█▅▁')
})
})
describe('trendDirection', () => {
it('returns stable for fewer than 3 values', () => {
expect(trendDirection([])).toBe('stable')
expect(trendDirection([1])).toBe('stable')
expect(trendDirection([1, 2])).toBe('stable')
})
it('detects rising trend', () => {
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
})
it('detects falling trend', () => {
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
})
it('returns stable for flat data', () => {
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
})
it('returns stable for small fluctuations within 10%', () => {
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
})
it('detects rising when baseline is zero but current is non-zero', () => {
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
})
it('returns stable when both halves are zero', () => {
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
})
})
describe('trendArrow', () => {
it('returns correct emoji for each direction', () => {
expect(trendArrow('rising')).toBe('📈')
expect(trendArrow('falling')).toBe('📉')
expect(trendArrow('stable')).toBe('➡️')
})
})

View File

@@ -61,3 +61,53 @@ export function formatSignificance(
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
export function sparkline(values: number[]): string {
if (values.length === 0) return ''
if (values.length === 1) return SPARK_CHARS[3]
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min
return values
.map((v) => {
if (range === 0) return SPARK_CHARS[3]
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
return SPARK_CHARS[idx]
})
.join('')
}
export type TrendDirection = 'rising' | 'falling' | 'stable'
export function trendDirection(values: number[]): TrendDirection {
if (values.length < 3) return 'stable'
const half = Math.floor(values.length / 2)
const firstHalf = values.slice(0, half)
const secondHalf = values.slice(-half)
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
const changePct = ((secondMean - firstMean) / firstMean) * 100
if (changePct > 10) return 'rising'
if (changePct < -10) return 'falling'
return 'stable'
}
export function trendArrow(dir: TrendDirection): string {
switch (dir) {
case 'rising':
return '📈'
case 'falling':
return '📉'
case 'stable':
return '➡️'
}
}

View File

@@ -9,13 +9,10 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -23,7 +20,6 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -98,12 +94,17 @@ onMounted(() => {
}
})
}
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -1,5 +1,14 @@
@import '@comfyorg/design-system/css/style.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,
.disable-animations *::before,
.disable-animations *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
@media (prefers-reduced-motion: no-preference) {
/* List transition animations */
.list-scale-move,

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#2b2f38",
"NODE_TITLE_COLOR": "#b2b7bd",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2b2f38",
"NODE_DEFAULT_BGCOLOR": "#242730",
"NODE_DEFAULT_BOXCOLOR": "#6e7581",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 22,
"WIDGET_BGCOLOR": "#2b2f38",
"WIDGET_OUTLINE_COLOR": "#6e7581",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -25,10 +25,8 @@
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#FFF",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
@@ -37,7 +35,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#222",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_TEXT_COLOR": "#DDD",

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#040506",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#161b22",
"NODE_DEFAULT_BGCOLOR": "#13171d",
"NODE_DEFAULT_BOXCOLOR": "#30363d",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#161b22",
"WIDGET_OUTLINE_COLOR": "#30363d",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -26,10 +26,8 @@
"CLEAR_BACKGROUND_COLOR": "lightgray",
"NODE_TITLE_COLOR": "#222",
"NODE_SELECTED_TITLE_COLOR": "#000",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#444",
"NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#F7F7F7",
"NODE_DEFAULT_BGCOLOR": "#F5F5F5",
"NODE_DEFAULT_BOXCOLOR": "#CCC",
@@ -38,7 +36,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#D4D4D4",
"WIDGET_OUTLINE_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#222",

View File

@@ -34,9 +34,7 @@
"CLEAR_BACKGROUND_COLOR": "#212732",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#e5eaf0",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#bcc2c8",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#2e3440",
"NODE_DEFAULT_BGCOLOR": "#161b22",
"NODE_DEFAULT_BOXCOLOR": "#545d70",
@@ -45,7 +43,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#2e3440",
"WIDGET_OUTLINE_COLOR": "#545d70",
"WIDGET_TEXT_COLOR": "#bcc2c8",

View File

@@ -19,9 +19,7 @@
"litegraph_base": {
"NODE_TITLE_COLOR": "#fdf6e3",
"NODE_SELECTED_TITLE_COLOR": "#A9D400",
"NODE_TEXT_SIZE": 14,
"NODE_TEXT_COLOR": "#657b83",
"NODE_SUBTEXT_SIZE": 12,
"NODE_DEFAULT_COLOR": "#094656",
"NODE_DEFAULT_BGCOLOR": "#073642",
"NODE_DEFAULT_BOXCOLOR": "#839496",
@@ -30,7 +28,6 @@
"NODE_BYPASS_BGCOLOR": "#FF00FF",
"NODE_ERROR_COLOUR": "#E00",
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DEFAULT_GROUP_FONT": 24,
"WIDGET_BGCOLOR": "#002b36",
"WIDGET_OUTLINE_COLOR": "#839496",
"WIDGET_TEXT_COLOR": "#fdf6e3",

View File

@@ -0,0 +1,28 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
/**
* Regression test: the graph-canvas-panel SplitterPanel must not clip
* absolutely-positioned children (like GraphCanvasMenu).
*
* PrimeVue applies `overflow: hidden` to all SplitterPanels by default.
* Without an explicit `overflow-visible` override, the bottom-right canvas
* toolbar becomes invisible on mobile viewports where the panel's bounding
* box is smaller than the full canvas area.
*
* @see https://www.notion.so/Bug-Graph-canvas-toolbar-not-visible-on-mobile-3246d73d36508144ae00f10065c42fac
*/
describe('LiteGraphCanvasSplitterOverlay', () => {
it('graph-canvas-panel has overflow-visible to prevent clipping toolbar on mobile', () => {
const filePath = resolve(__dirname, 'LiteGraphCanvasSplitterOverlay.vue')
const source = readFileSync(filePath, 'utf-8')
// The SplitterPanel wrapping graph-canvas-panel must include overflow-visible
// to override PrimeVue's default overflow:hidden on .p-splitterpanel.
// Without this, GraphCanvasMenu (absolute right-0 bottom-0) gets clipped on mobile.
expect(source).toMatch(
/class="[^"]*graph-canvas-panel[^"]*overflow-visible/
)
})
})

View File

@@ -72,7 +72,7 @@
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel class="graph-canvas-panel relative">
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel

View File

@@ -71,8 +71,8 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -14,6 +13,7 @@ import {
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { render, screen } from '@/utils/test-utils'
import ComfyQueueButton from './ComfyQueueButton.vue'
@@ -78,38 +78,40 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(job)
}
function createWrapper() {
const stubs = {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
function renderQueueButton() {
const pinia = createTestingPinia({ createSpy: vi.fn })
return mount(ComfyQueueButton, {
return render(ComfyQueueButton, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
},
stubs: {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
stubs
}
})
}
describe('ComfyQueueButton', () => {
it('renders the batch count control before the run button', () => {
const wrapper = createWrapper()
const controls = wrapper.get('.queue-button-group').element.children
renderQueueButton()
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit')
expect(controls[1]).toHaveAttribute('data-testid', 'queue-button')
})
it('keeps the run instant presentation while idle even with active jobs', async () => {
const wrapper = createWrapper()
renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
@@ -117,29 +119,27 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]')
const queueButton = screen.getByTestId('queue-button')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
})
it('switches to stop presentation when instant mode is armed', async () => {
const wrapper = createWrapper()
renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
queueSettingsStore.mode = 'instant-running'
await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]')
const queueButton = screen.getByTestId('queue-button')
expect(queueButton.text()).toContain('Stop Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('destructive')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
expect(queueButton).toHaveTextContent('Stop Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'destructive')
})
it('disarms instant mode without interrupting even when jobs are active', async () => {
const wrapper = createWrapper()
const { user } = renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
@@ -148,33 +148,26 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await user!.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
const queueButton = screen.getByTestId('queue-button')
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
expect(commandStore.execute).not.toHaveBeenCalled()
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('activates instant running mode when queueing again', async () => {
const wrapper = createWrapper()
const { user } = renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await user!.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-running')

View File

@@ -1,12 +1,11 @@
<template>
<a
<div
ref="wrapperRef"
v-tooltip.bottom="{
value: tooltipText,
showDelay: 512
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
@@ -23,7 +22,7 @@
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
</div>
<Menu
v-if="isActive || isRoot"
ref="menu"

View File

@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
widgetName,
label: widget.label,
subLabel: node.title,
rename: () => promptRenameWidget(widget, node, t)
canRename: true
}
})
)
@@ -74,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -234,7 +244,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
widgetName,
label,
subLabel,
rename
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
@@ -242,7 +252,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:can-rename="canRename"
:remove="
() =>
remove(
@@ -250,6 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
</DraggableList>
</PropertiesAccordionItem>

View File

@@ -6,10 +6,13 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -17,6 +20,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -38,8 +42,10 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
@@ -97,21 +103,27 @@ const mappedSelections = computed((): WidgetEntry[] => {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
}
@@ -127,6 +139,21 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
async function handleDragDrop(e: DragEvent) {
for (const { nodeData } of mappedSelections.value) {
if (!nodeData?.onDragOver?.(e)) continue
const rawResult = nodeData?.onDragDrop?.(e)
if (rawResult === false) continue
e.stopPropagation()
e.preventDefault()
if ((await rawResult) === true) return
}
}
defineExpose({ handleDragDrop })
</script>
<template>
<div
@@ -181,7 +208,11 @@ function nodeToNodeData(node: LGraphNode) {
]"
>
<template #button>
<Button variant="textonly" size="icon">
<Button
variant="textonly"
size="icon"
data-testid="widget-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -2,31 +2,43 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
const isEditing = ref(false)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{
const { title, canRename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
canRename?: boolean
remove?: () => void
}>()
const emit = defineEmits<{
rename: [newName: string]
}>()
function onEditComplete(newName: string) {
isEditing.value = false
const trimmed = newName.trim()
if (trimmed && trimmed !== title) emit('rename', trimmed)
}
const entries = computed(() => {
const items = []
if (rename)
if (canRename)
items.push({
label: t('g.rename'),
command: rename,
command: () => setTimeout(() => (isEditing.value = true)),
icon: 'icon-[lucide--pencil]'
})
if (remove)
@@ -43,13 +55,24 @@ const entries = computed(() => {
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
data-testid="builder-io-item"
>
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
<div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
class="drag-handle truncate text-sm"
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
<EditableText
:model-value="title"
:is-editing="isEditing"
:input-attrs="{ class: 'p-1' }"
:class="
cn(
'drag-handle h-5 text-sm',
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
!isEditing && 'truncate'
)
"
data-testid="builder-io-item-title"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
label-class="drag-handle"
label-type="div"
@dblclick="canRename && (isEditing = true)"
@edit="onEditComplete"
@cancel="isEditing = false"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
@@ -63,7 +86,7 @@ const entries = computed(() => {
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<Button variant="muted-textonly" data-testid="widget-actions-menu">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -0,0 +1,55 @@
<template>
<NotificationPopup
v-if="appModeStore.showVueNodeSwitchPopup"
:title="$t('appBuilder.vueNodeSwitch.title')"
show-close
position="bottom-left"
@close="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.content') }}
<template #footer-start>
<label
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
>
<input
v-model="dontShowAgain"
type="checkbox"
class="accent-primary-background"
/>
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
</label>
</template>
<template #footer-end>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
</Button>
</template>
</NotificationPopup>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import NotificationPopup from '@/components/common/NotificationPopup.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
const appModeStore = useAppModeStore()
const settingStore = useSettingStore()
const dontShowAgain = ref(false)
function dismiss() {
if (dontShowAgain.value) {
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
}
appModeStore.showVueNodeSwitchPopup = false
}
</script>

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