Compare commits

...

25 Commits

Author SHA1 Message Date
dante01yoon
6b91a570db fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) 2026-05-27 20:41:40 +09:00
dante01yoon
64d099f6cf test: add failing test for IC-LoRA Loader Model Only missing cloud models (FE-838) 2026-05-27 20:32:00 +09:00
Comfy Org PR Bot
d05eadba97 1.46.3 (#12474)
Patch version increment to 1.46.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>
2026-05-27 07:55:47 +00:00
Alexander Brown
0157b47024 feat(subgraph): Subgraph Link Only Promotion (ADR 0009) + migration/store hygiene (#12197)
## Summary

Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for
surfacing inner subgraph widgets on the parent SubgraphNode by
*promoting through links* rather than by duplicating widget state on the
host. Ships with the hygiene/refactor pass on the migration, store, and
event layers that the new model depends on.

## What changes

### Subgraph Link Only Promotion (ADR 0009)

Promoted widgets are defined by the link from a SubgraphNode input to
the interior node, not by a duplicated widget instance on the host.
Consequences:

- A SubgraphNode renders inner widgets purely as a **projection** of the
interior widgets and links — no host-side state to drift.
- **Per-host independence**: multiple instances of the same SubgraphNode
render and edit their own values without cross-talk.
- **Reversible promote/demote**: structural link operation, so demote
preserves host slots and external connections (#12278).

### Supporting refactors

- **Migration** — Planner/classifier/repair/quarantine helpers collapsed
into a single `proxyWidgetMigration` entry point with black-box
round-trip coverage. Honors the source-node-id disambiguator on
`proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to
the right interior widget.
- **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted
widget state is keyed by entityId across the store, DOM, and migration
paths.
- **SubgraphNode** — 3-key promoted-view cache replaced with a single
version counter + explicit `invalidatePromotedViews()` at mutation
sites; `id === -1` sentinel removed.
- **Events** — `LGraph.trigger()` now dispatches node trigger payloads
through `this.events`, replacing a leaky `onTrigger` monkey-patch.
`SubgraphEditor` reactivity is driven from subgraph events instead of
imperative refresh.
- **Stores** — `appModeStore` migration helpers collapsed into
`upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from
the locator index; `previewExposureStore` cleanup and cycle-detection
double-warn fix.
- **Misc** — `Outcome` types consolidated; mutable accumulators replaced
with `flatMap`; new ESLint rule forbids litegraph imports under
`src/world/`.

### Tests

- Browser tests for promoted widgets retagged `@vue-nodes` and rewritten
to assert against the rendered Vue node DOM (via `getNodeLocator` /
`getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate`
graph introspection.
- Per-host widget independence asserted via DOM.
- Migration coverage moved to black-box round-trip tests.
- Added coverage for duplicate-named promoted widget identity (ADR 0009)
and the per-parent demote branch in `WidgetActions`.

## Review focus

- ADR 0009 conformance of the link-only promotion model.
- Disambiguator resolution path in `proxyWidgetMigration`.
- Single-version-counter promoted-view cache and its
`invalidatePromotedViews()` call sites.
- `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue`
migration off `onTrigger` (FE-667 tracks the remaining
`useGraphNodeManager` conversion).

## Breaking changes

None for users. Internal subgraph promotion APIs changed — see ADR 0009.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:29:11 -07:00
Robin Huang
7599c6a1ca chore: remove EA logo from website social proof bar (#12477)
Removes the EA (Electronic Arts) logo from the client social proof bar
on the website. Drops it from the logos list in
`SocialProofBarSection.vue` and deletes the now-unused `EA.svg` asset.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 05:05:36 +00:00
AustinMroz
876ed502c9 Mock sign-in request in test (#12482)
Mock the sign in request in e2e tests to ensure tests success isn't tied
to external variables.
2026-05-27 05:05:26 +00:00
AustinMroz
ff8a19f233 Fix errant subscription popups with workspaces (#12472)
Under unreliable circumstances, users who have an active subscription
through a workspace, but no personal subscription would see a popup
suggesting they must purchase a workspace subscription.

This is suspected to be a consequence of the feature flag for team
workspaces having its initialization delayed such that subscription
state is incorrectly decided by the user's subscription state. As a
temporary remedy, the feature flag state is cached to local storage.

This caching is typed to the feature flag itself to ensure this PR gets
cleaned up when the feature flag is changed or removed in the future.
2026-05-27 01:58:25 +00:00
AustinMroz
c638ad194b Fix restoring values to dynamic combos (#12211)
`DynamicCombo`s redefined `widget.value` without going through the
store. This would result in desync of state. Most noticeably, swapping
to and from a workflow would break vue reactivity and cause the default
option to display visually

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12211-Fix-restoring-values-to-dynamic-combos-35f6d73d3650814ba12ccda42615239a)
by [Unito](https://www.unito.io)
2026-05-26 21:19:59 +00:00
Alexander Brown
b166532b24 chore: One more unnecessary -- (#12468)
` -- `
2026-05-26 21:05:48 +00:00
pythongosssss
5a7b1d6a90 fix: improve read only vue node widget contrast (#12455)
## Summary

When a text widget in Vue nodes has an upstream node connected to it,
the widget becomes read-only. However, the disabled state token color is
virtually the same as the default node background color, making it
difficult to visually distinguish that the widget is disabled.

This update changes readonly widgets to use a darker background

## Changes

- **What**: 
- add read only widget bg style

## Screenshots (if applicable)

Before
<img width="2556" height="1858" alt="Screen Shot 2026-05-25 at 06 05 43"
src="https://github.com/user-attachments/assets/897a5157-8d4a-4258-9bca-41ca0289bfb6"
/>

After
<img width="2556" height="1858" alt="Screen Shot 2026-05-25 at 06 04 59"
src="https://github.com/user-attachments/assets/a052d040-8a26-4bea-a998-9dde1734a71a"
/>

Light theme:
<img width="550" height="654" alt="image"
src="https://github.com/user-attachments/assets/52d898c7-0c71-4bd8-a5bd-426e8dc5e8b0"
/>
2026-05-26 09:05:36 +00:00
Comfy Org PR Bot
682bd14061 1.46.2 (#12458)
Patch version increment to 1.46.2

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-26 04:23:34 +00:00
Alexander Brown
5b48bf67a9 docs: drop misleading pnpm test:unit -- examples (#12460)
## Summary

- Drop the `--` separator from all in-repo `pnpm test:unit -- <args>`
examples. The separator is unnecessary (pnpm forwards extra args
automatically) and on Windows PowerShell it mangles quoted args like `-t
"restores host values by input name"`, splitting them into multiple
tokens.
- Add a short note in `docs/guidance/vitest.md` explaining the
substring-match semantics of the positional filter and that `-t` matches
`it()`/`test()` names only (not `describe()` blocks).
- Fix `pnpm test:unit -- run <files>` in the backport-management skill:
because `test:unit` is already `vitest run`, the literal `run` token was
a positional path filter that silently narrowed the suite to files whose
paths contain "run".

## Test plan

- [ ] `pnpm test:unit useConflictAcknowledgment` matches
`useConflictAcknowledgment.test.ts`
- [ ] `pnpm test:unit SubgraphWidgetPromotion.test.ts -t "restores host
values"` filters to a single test
- [ ] `git grep "pnpm test:unit -- "` returns no in-repo matches
2026-05-26 03:08:24 +00:00
AustinMroz
bbaaa82125 Fix missing value control on 'Primitive Int' (#12431)
#8505 added support for specifying default values for
`control_after_generate`. Unbeknown to me, this exact same format of
assigning `control_after_generate` to a string in the schema already
served a function of renaming the control widget. As a result, control
widgets with a default value set would use a different internal name,
but due to other overlapping systems, would either have a label of
`control_after_generate` or `control_before_generate`.

The fix here, is incredibly simple and low scope. Instead of trying to
filter control widgets by name, the dedicated `IS_CONTROL_WIDGET` symbol
is used.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5917e093-124a-4923-80ff-321fc0a94ef3"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c6d95b5a-2764-4e71-a09f-dcae5ddcfdbb"
/>|
2026-05-26 01:49:44 +00:00
Alexander Brown
601cec68b9 chore: require Node >=25 and pnpm >=11.3 (#12459)
## Summary

Bump required Node.js to `>=25` and pnpm to `>=11.3`.

## Changes

- **What**: Updated `engines.node` to `>=25`, `engines.pnpm` to
`>=11.3`, and `packageManager` to `pnpm@11.3.0` in `package.json`.
Bumped `.nvmrc` from `24` to `25`.
- **Breaking**: Contributors and CI must now use Node 25+ and pnpm
11.3+.

## Review Focus

CI workflows resolve Node from `.nvmrc`, so they pick up the bump
automatically. Confirm no downstream tooling pins an older Node/pnpm.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-05-26 01:27:28 +00:00
Alexander Brown
8d1a170136 feat: remove ability to create Group Nodes (#12347)
*PR Created by the Glary-Bot Agent*

---

Group Nodes are a legacy feature superseded by Subgraphs. This PR
removes every UI entry point for *creating* a new Group Node while
keeping the loading, ungrouping, and management code intact so existing
workflows that contain Group Nodes continue to load and can still be
unpacked or managed.

## Removed creation entry points

- `Comfy.GroupNode.ConvertSelectedNodesToGroupNode` command
- `Alt+G` keybinding
- "Convert to Group Node (Deprecated)" canvas and node right-click menu
items (`groupNode.ts` `getCanvasMenuItems` / `getNodeMenuItems`)
- "Convert to Group Node" entry in the Vue selection menu
(`useSelectionMenuOptions.ts`)
- Associated `MENU_ORDER` entry in `contextMenuConverter.ts`
- `convertSelectedNodesToGroupNode` / `convertDisabled` helpers in
`groupNode.ts`
- `BadgeVariant.DEPRECATED` enum member (no remaining consumers;
knip-clean)
- Matching `en` locale strings in `main.json` (`contextMenu.Convert to
Group Node`, `commands.Convert selected nodes to group node`) and
`commands.json` (`Comfy_GroupNode_ConvertSelectedNodesToGroupNode`)
- Browser-test helpers `convertToGroupNode` /
`convertAllNodesToGroupNode` and the three tests that exercised the
creation flow

## Preserved (intentionally)

- `GroupNodeHandler`, `GroupNodeConfig`, `GroupNodeBuilder`,
`ManageGroupDialog`
- `beforeConfigureGraph` / `nodeCreated` hooks that load and initialize
Group Nodes from saved workflows
- "Manage Group Nodes" canvas menu item, the
`Comfy.GroupNode.ManageGroupNodes` command, and the per-node "Manage
Group Node" / "Convert to nodes" options on existing group node
instances
- "Ungroup selected group nodes" command + `Alt+Shift+G` keybinding so
users can disassemble existing group nodes in legacy workflows
- Reduced `browser_tests/tests/groupNode.spec.ts` covering surviving
behaviors: workflow loading (legacy `/` separator, hidden-input config,
v1.3.3 fixture), copy/paste of already-loaded group nodes across
workflows, and opening the Manage Group Node dialog

## Verification

- `pnpm typecheck` clean
- `pnpm typecheck:browser` clean
- `pnpm format` clean
- `pnpm knip` clean (no new findings; pre-existing flac.ts tag warning
unchanged)
- `pnpm test:unit` — 796 files, 10,789 tests pass (8 pre-existing
skipped); includes a regression test in
`useSelectionMenuOptions.test.ts` asserting the Vue selection menu no
longer offers a Convert to Group Node option
- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:browser)
passed
- Manual verification against a live dev server: programmatically
inspecting the GroupNode extension showed `getCanvasMenuItems` returns
only `[Manage Group Nodes]`, `getNodeMenuItems` returns `[]`, and the
`ConvertSelectedNodesToGroupNode` command + Alt+G keybinding are absent
from the registries. Visually captured the node right-click menu
(attached screenshot) — "Convert to Subgraph" remains, no "Convert to
Group Node" entry
- Browser E2E suite not executed locally (sandbox has no GPU and
Playwright requires a full backend; the reduced spec will run in CI)
- Non-English locales not modified — per `src/locales/CONTRIBUTING.md`
they are regenerated by CI

## Notes for reviewers

- This is a surgical removal of creation only; loading any older
workflow that already contains group nodes will continue to work.
- If you'd like to also remove the management UI (`Manage Group Nodes`
command/menu/dialog) or the ungroup command in a follow-up, happy to
open a separate PR.

## Screenshots

![Node right-click context menu after the change: lists Convert to
Subgraph and standard node options, with no Convert to Group Node
entry](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/51318b42bce8def1eb9f98252fbfc2bf097a0c68f001ae6f7a15f1344abf3b91/pr-images/1779234588218-3d0e305a-0167-44c4-8ae3-ea6e785ca418.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12347-feat-remove-ability-to-create-Group-Nodes-3656d73d365081d488bfd98ffd7545c0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-26 00:47:30 +00:00
imick-io
08ee925811 theme-aware favicon for light and dark mode (#12457)
## Summary

Serve separate SVG favicons via prefers-color-scheme so the icon stays
legible against both light and dark browser chrome. Drop the
unreferenced favicon.svg / favicon.png; keep favicon.ico as the legacy
fallback.

## Changes

- What: apps/website/src/layouts/BaseLayout.astro now links
favicon-light.svg and favicon-dark.svg gated on prefers-color-scheme,
with favicon.ico retained as the legacy fallback. Unreferenced
favicon.svg / favicon.png removed from apps/website/public/.

## Review Focus

- Naming convention: favicon-light.svg is the asset served in light mode
(dark-backgrounded icon for contrast against light chrome);
favicon-dark.svg is served in dark mode. Confirm this matches
expectation.
- Safari fallback: older Safari versions ignore prefers-color-scheme on
<link rel="icon"> and will fall through to favicon.ico — that file is
unchanged and should look acceptable in both modes.

## Screenshots

Dark mode:
<img width="224" height="30" alt="image"
src="https://github.com/user-attachments/assets/5fa3c620-0021-4c90-bc18-013cd6ef45cf"
/>

Light mode:
<img width="227" height="28" alt="image"
src="https://github.com/user-attachments/assets/54a130e1-f976-46e8-b047-e27efe22e479"
/>
2026-05-25 19:20:41 +00:00
AustinMroz
fb5b4a62ba Fix mask editor sometimes showing wrong image (#12413)
Mask editor checks `node.images` to determine the image which is edited.
If the user generates an output image in litegraph mode, swaps to vue
mode, then generates a new image, the mask editor will incorrectly
display the image last shown in litegraph mode.

This is resolved by having `syncLegacyNodeImgs` also synchronize node
outputs to `node.images`.
2026-05-25 18:37:10 +00:00
pythongosssss
cb62604d21 feat: map more custom node loaders to asset picker (#12340)
## Summary

Updates the following node mappings to show the new model picker dialog:
- DynamiCrafterModelLoader > checkpoints
(d312c62982/nodes.py (L367))
- DynamiCrafterCNLoader > controlnet
(d312c62982/nodes.py (L217))
- MelBandRoFormerModelLoader > diffusion_models
(92c86854e6/nodes.py (L31))
- INPAINT_LoadFooocusInpaint > inpaint (head + patch inputs)
(b32f293d3f/nodes.py (L129))
- AILab_QwenVL_Advanced > LLM/Qwen-VL/* (10 variants) ( "model_name":
"Pick the Qwen-VL checkpoint. First run downloads weights into
models/LLM/Qwen-VL, so leave disk space.",)

## Changes

- **What**:  Add custom node mappings

## Review Focus

## 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-12340-feat-map-more-custom-node-loaders-to-asset-picker-3656d73d365081929c0dc9179a9e9c67)
by [Unito](https://www.unito.io)
2026-05-25 18:35:53 +00:00
Terry Jia
d02c5d374f chore: tidy up retain-view-on-reload follow-ups (#12441)
## Summary
Drop a no-op cn() wrapper on the static camera-switch icon and trim the
two over-long comments around setRetainViewOnReload / _loadModelInternal
down to one line each.
2026-05-25 17:20:08 +00:00
jaeone94
c0ef283a05 General execution error messaging (#12448)
## Summary

This PR builds on the error catalog display resolver foundation from
#12402 and adds the first broader catalog pass for general
execution-related messaging. The goal is to keep the raw API contract
intact (`message` / `details`) while adding resolved display fields that
the UI can prefer when catalog copy exists.

The main functional sample in this PR is validation error messaging. It
expands the resolver beyond `required_input_missing` so common node
validation failures can show friendlier titles, grouped messages, detail
copy, item labels, and toast copy without overwriting the original
backend payload.

## What changed

- Added catalog copy for known node validation errors:
  - `required_input_missing` as `missing_connection`
  - `bad_linked_input`
  - `return_type_mismatch`
  - `invalid_input_type`
  - `value_smaller_than_min`
  - `value_bigger_than_max`
  - `value_not_in_list`
  - `custom_validation_failed`
  - `exception_during_inner_validation`
  - `exception_during_validation`
  - `dependency_cycle`
- Added an `image_not_loaded` validation override for
`custom_validation_failed` messages that indicate invalid image files or
directory paths.
- Added value-aware validation details when Core provides structured
`extra_info`, including received values, expected/received types, and
min/max bounds.
- Added prompt-level catalog handling for known prompt errors that
already have stable types/copy, including missing node type, prompt
output validation, image download, and OOM prompt errors.
- Preserved runtime execution errors as raw API copy for now, so
service-level or actionable runtime failures are not hidden behind
generic catalog text before targeted runtime handling lands.
- Added/updated English `errorCatalog` i18n keys for the new validation
and prompt catalog copy.
- Added resolver and grouping tests for the new catalog paths, raw
fallback behavior, runtime raw preservation, prompt copy, and
image-not-loaded detection.

## Screenshots (diff)
### Before  
<img width="371" height="346" alt="Old_1"
src="https://github.com/user-attachments/assets/bd474869-7428-4f68-a067-bb412aa95d3b"
/>
<img width="373" height="296" alt="Old_2"
src="https://github.com/user-attachments/assets/fc393792-dc6d-46fb-b7df-20290b35e30e"
/>
<img width="370" height="292" alt="Old_3"
src="https://github.com/user-attachments/assets/bcb867ea-12ba-49b7-887a-ce06afa60475"
/>
<img width="370" height="269" alt="Old_4"
src="https://github.com/user-attachments/assets/05caeff8-2597-4c95-97cf-2736825b85f3"
/>
<img width="371" height="292" alt="Old_5"
src="https://github.com/user-attachments/assets/dd58113e-5953-4701-b597-d59cb6e124e9"
/>
<img width="373" height="282" alt="Old_6"
src="https://github.com/user-attachments/assets/60fb02c0-4ed6-4734-926c-f8a20f0aeb1c"
/>
<img width="371" height="279" alt="Old_7"
src="https://github.com/user-attachments/assets/a3453b5c-c779-4f43-af27-97cc9a083480"
/>
<img width="370" height="292" alt="Old_8"
src="https://github.com/user-attachments/assets/59d08636-c1b3-4cde-a340-befb48726ee8"
/>
<img width="371" height="276" alt="Old_9"
src="https://github.com/user-attachments/assets/7a94465b-ed5c-4ad9-a40a-cfe3c08d3dc7"
/>
<img width="368" height="279" alt="Old_10"
src="https://github.com/user-attachments/assets/3f791ff3-e3e3-4cb7-aab1-640ec1cee751"
/>
<img width="370" height="276" alt="Old_11"
src="https://github.com/user-attachments/assets/9c0f28c2-4f60-4f38-b3c4-5560609e329e"
/>
<img width="370" height="279" alt="Old_12"
src="https://github.com/user-attachments/assets/4b61545e-db7e-4512-b300-e883ab37f347"
/>

### After
<img width="426" height="301" alt="New_1"
src="https://github.com/user-attachments/assets/9874c036-2b3d-4b7c-ac3d-cb9c396c597f"
/>
<img width="421" height="301" alt="New_2"
src="https://github.com/user-attachments/assets/38cd0f35-53a4-490a-b47f-da21eaa44fc8"
/>
<img width="418" height="347" alt="New_3"
src="https://github.com/user-attachments/assets/db5ab3cc-f246-407d-b80b-9ad92c95c7ad"
/>
<img width="425" height="327" alt="New_4"
src="https://github.com/user-attachments/assets/4333c2b8-3077-4122-9719-21d56a7b2230"
/>
<img width="424" height="325" alt="New_5"
src="https://github.com/user-attachments/assets/6616d61f-fa90-4d2f-b8fd-50ac5a3f32cb"
/>
<img width="423" height="326" alt="New_6"
src="https://github.com/user-attachments/assets/02a4f97a-708e-4c00-b061-d8e4dcaacd8f"
/>
<img width="424" height="323" alt="New_7"
src="https://github.com/user-attachments/assets/9d1e96c9-69de-4e26-a152-1a101675c5eb"
/>
<img width="425" height="327" alt="New_8"
src="https://github.com/user-attachments/assets/ffa66faf-1a33-43a3-b604-25352195f28c"
/>
<img width="425" height="323" alt="New_9"
src="https://github.com/user-attachments/assets/f7eb5f0c-4d0c-4f1b-aa3d-30358fbc9943"
/>
<img width="423" height="328" alt="New_10"
src="https://github.com/user-attachments/assets/72665c97-ec61-4e5a-b702-379baf919822"
/>
<img width="423" height="351" alt="New_11"
src="https://github.com/user-attachments/assets/c5376f02-7a62-42e6-9cda-e50ab6d41b04"
/>
<img width="425" height="326" alt="New_12"
src="https://github.com/user-attachments/assets/413df105-dc7e-4289-90b0-30ecaa417c84"
/>


## Intentional boundaries


This PR does not add targeted runtime/cloud-specific message matching
yet. Runtime execution errors still use the original exception message
and traceback in the error panel. This is intentional because
cloud/service runtime errors can include actionable strings such as
auth, payment, rate limit, timeout, moderation, or infrastructure
failures, and collapsing those too early would make the UX worse.

This PR also does not change the overlay or right-side panel design. It
only prepares and fills resolved display fields so the next stacked PRs
can consume them with much less plumbing.

## Follow-up PR plan

- Add targeted runtime/cloud-specific messaging for high-volume errors
such as credits, timeouts, disallowed content, rate limit,
sign-in/payment requirements, and server crash style failures.
- Revisit runtime execution grouping once runtime catalog IDs are
explicit enough to group by message category rather than node class or
raw exception text.
- Update the error overlay to use single-error toast title/message
fields and multi-error aggregate copy.
- Update the right-side error panel design, including item labels such
as `Node name - input/widget name`.
- Consider splitting `errorMessageResolver.ts` by error family
(`validation`, `prompt`, `runtime`, `cloud-specific`) before adding more
runtime-specific rules.

## Validation

- `pnpm exec vitest run
src/platform/errorCatalog/errorMessageResolver.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- Commit hooks ran staged formatting, lint fixes, and `pnpm typecheck`.
- Push hook ran `knip --cache`; it completed with an existing tag-hint
warning for `src/scripts/metadata/flac.ts`.
2026-05-25 12:54:47 +00:00
Dante
d405002127 fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12447)
## Summary

Fix the duplicate \`<WidgetColorPicker>\` rendering on the \`Color to
RGB Int\` node (and any other COLOR-using V3 node that the runtime
double-registers a widget for).
<img width="480" alt="after-fix-dedupe-proof"
src="https://github.com/user-attachments/assets/5c801806-ed5d-493f-92b6-e0b99dd8e408"
/>

## Changes

- **What**:
- \`useProcessedWidgets.getWidgetIdentity\`: fall back to the host
\`nodeId\` parameter for the dedupe identity root when neither
\`storeNodeId/widget.nodeId\` nor \`sourceExecutionId\` is set. Normal
root-graph widgets now dedupe identically to promoted/execution-scoped
widgets, so any duplicate same-name+same-type widget collapses to one
render. \`sourceExecutionId\` precedence is preserved.
- \`useColorWidget\`: read top-level \`default\` from the V2 spec (falls
back to nested \`options.default\` for hand-authored V2 specs), and
short-circuit if a same-name color widget already exists on
\`node.widgets\` so a second \`addWidget('color', …)\` call from
upstream hooks (or a \`configure\` round-trip) no longer duplicates the
row.
- **Tests**:
- New \`useColorWidget.test.ts\` covers top-level default,
nested-options fallback, no-default fallback, and the idempotency guard.
- \`useProcessedWidgets.test.ts\` gets a regression case for two
identical color widgets on the same node collapsing to one render, plus
an updated \`getWidgetIdentity\` case for the host-nodeId fallback.

## Review Focus

- \`getWidgetIdentity\` precedence change. The fallback only fires when
none of \`storeNodeId\`, \`widget.nodeId\`, or \`sourceExecutionId\` are
present, so promoted/exec-scoped widgets (incl. the \"unresolved
same-name promoted entries distinct by source execution identity\"
\`NodeWidgets\` test) are unaffected.
- \`useColorWidget\` idempotency guard is defensive — the root cause of
the second \`addWidget\` call (cloud-only hook or persisted
\`info.widgets\` configure round-trip) is not in this diff; that's
tracked separately.

Fixes
[FE-842](https://linear.app/comfyorg/issue/FE-842/color-to-rgb-int-node-shows-duplicate-color-widgets)
2026-05-25 11:33:58 +00:00
pythongosssss
abd233d10d feat: default search to essentials when graph is empty (#12377)
## Summary

Currently, when opening node search on an empty graph, the default view
shows "Most Relevant" nodes, which includes nodes like CLIP and VAE. For
users building from scratch, these nodes are not necessarily the most
helpful starting point.

## Changes

- **What**: 
- Update default mode to Essentials when graph is empty

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12377-feat-default-search-to-essentials-when-graph-is-empty-3666d73d3650816d9d5ae3ed602a30ec)
by [Unito](https://www.unito.io)
2026-05-25 09:35:52 +00:00
Comfy Org PR Bot
e1049a99a3 1.46.1 (#12445)
Patch version increment to 1.46.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-25 01:33:11 +00:00
Jukka Seppänen
3da6e1766e feat: optional retain camera view on Load3D model reload (#12440)
When comparing outputs from 3D generations, it's very hard to see small
differences since the camera always resets. This adds an option to lock
the camera, so only the model refreshes.

## Summary

Adds an opt-in per-node toggle that preserves the current camera view
(position, target, zoom, camera type) across model loads in Load3D /
Load3DAnimation nodes, instead of resetting to default framing.

## Changes

- **What**: New `retainViewOnReload?: boolean` field on `CameraConfig`,
a `Load3d.setRetainViewOnReload()` setter wired through the existing
`useLoad3d` camera-config watcher, capture/restore logic in
`Load3d._loadModelInternal`, and a lock-icon toggle button in
`CameraControls.vue` below the FOV slider. Preference persists via the
existing `node.properties['Camera Config']` mechanism.

## Review Focus

- **First-load semantics**: retain only kicks in once a model has
successfully loaded at least once (`hasLoadedModel` flag), so the
default `setupForModel` framing wins on a fresh node. `clearModel()`
resets the flag so the next load also reframes.
- **Restore order vs. `SceneModelManager.setupModel`**: the scene model
manager unconditionally calls `setupForModel` during a load, which
clobbers the camera. The restore in `_loadModelInternal` runs *after*
the load completes, on top of that framing.
- **Camera-type mismatch**: if the saved state's `cameraType` differs
from the currently active camera, `toggleCamera()` runs before
`setCameraState()` so the perspective/orthographic camera being restored
is actually the active one. Covered by a dedicated test.
- **Scope**: only wired through `useLoad3d` (LiteGraph node controls).
The full-page viewer (`useLoad3dViewer` / `ViewerCameraControls`) is
deliberately not extended — the modal is mostly a one-shot
view-and-close flow, so retain there would add surface area for an
uncommon use case.
- **Failed loads**: `hasLoadedModel` only flips inside `if
(modelManager.currentModel)`, so a load that produces no model leaves
the flag where it was. Captured camera state is still applied on top,
which effectively no-ops since nothing reset it.


## Video


https://github.com/user-attachments/assets/880d6ad1-28a9-4413-83a3-8323d05d904a
2026-05-23 08:47:30 -04:00
Comfy Org PR Bot
52830a9e73 1.46.0 (#12439)
Minor version increment to 1.46.0

**Base branch:** `main`

---------

Co-authored-by: dante01yoon <6510430+dante01yoon@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-23 18:16:38 +09:00
230 changed files with 14314 additions and 9003 deletions

View File

@@ -32,12 +32,12 @@
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",

View File

@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
# ───────────────────────────────────────────────────────────────────────
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
# Guard each targeted command against empty file lists — running `pnpm test:unit`
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
pnpm typecheck
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
if [ ${#TEST_FILES[@]} -gt 0 ]; then
pnpm test:unit -- run "${TEST_FILES[@]}"
pnpm test:unit "${TEST_FILES[@]}"
else
echo "No changed test files — skipping targeted unit tests"
fi
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
## Validation
- `pnpm typecheck`
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
- `pnpm exec eslint <changed files>` ✅ (0 errors)
- `pnpm exec oxfmt --check` ✅ (clean)

View File

@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
```bash
# Vitest
pnpm test:unit -- <test-file>
pnpm test:unit <test-file>
# Playwright
pnpm test:browser:local -- --grep "<test name>"

View File

@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
```bash
# Instead of fixing the code, just updating the snapshot to match buggy output
pnpm test:unit -- --update
pnpm test:unit --update
```
If a snapshot needs updating, the fix should change the code behavior, not the expected output.

2
.nvmrc
View File

@@ -1 +1 @@
24
25

View File

@@ -307,6 +307,20 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3062_2148)">
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
</g>
<defs>
<clipPath id="clip0_3062_2148">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,14 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
</svg>

Before

Width:  |  Height:  |  Size: 615 B

View File

@@ -3,7 +3,6 @@ const logos = [
'Amazon Studios',
'Apple',
'Autodesk',
'EA',
'Harman',
'Hp',
'Lucid',

View File

@@ -71,8 +71,18 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link
rel="icon"
href="/favicon-light.svg"
type="image/svg+xml"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
href="/favicon-dark.svg"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"
/>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL.href} />

View File

@@ -0,0 +1,197 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["first-host", 11]
},
{
"id": 12,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [900, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["second-host", 22]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -0,0 +1,176 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["9999", "missing_widget"]]
},
"widgets_values": ["quarantined-host-value"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -213,7 +213,8 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
incrementButton: widget.getByTestId(TestIds.widgets.increment),
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
}
}

View File

@@ -17,8 +17,9 @@ export class SubgraphEditor {
)
}
async open(subgraphNode: Locator) {
async ensureOpen(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
if (await this.root.isVisible()) return
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
@@ -69,7 +70,7 @@ export class SubgraphEditor {
toState?: boolean
}
) {
await this.open(subgraphNode)
await this.ensureOpen(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)

View File

@@ -6,8 +6,9 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
* Widgets are located by `nodeId:widgetName` suffix against the
* `data-widget-key` attribute, which carries the canonical
* `graphId:nodeId:widgetName` WidgetEntityId.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
@@ -20,9 +21,9 @@ export class AppModeWidgetHelper {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
/** Get a widget item container by its `nodeId:widgetName` suffix. */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key="${key}"]`)
return this.container.locator(`[data-widget-key$=":${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */

View File

@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -216,16 +216,6 @@ export class NodeOperationsHelper {
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')

View File

@@ -14,6 +14,8 @@ import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
export class SubgraphHelper {
public readonly editor: SubgraphEditor
@@ -423,39 +425,9 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
> {
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))
})
return getAllHostPromotedWidgets(this.comfyPage)
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */

View File

@@ -152,6 +152,7 @@ export const TestIds = {
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
valueControl: 'value-control',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',

View File

@@ -511,19 +511,7 @@ export class NodeReference {
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
await this.comfyPage.contextMenu.clickMenuItem(optionText)
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')

View File

@@ -1,48 +1,77 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
function widgetSourceToEntry(
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
function previewExposureToEntry(
exposure: PreviewExposure
): PromotedWidgetEntry {
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
return (
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
export function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
'sourceNodeId' in widget &&
typeof widget.sourceNodeId === 'string' &&
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
) {
return [[widget.sourceNodeId, widget.sourceWidgetName]]
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
return []
})
}, nodeId)
},
nodeId
)
return normalizePromotedWidgets(raw)
const exposures = isNodeProperty(previewExposures)
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}
export async function getPromotedWidgetNames(
@@ -78,12 +107,29 @@ export async function getPromotedWidgetCountByName(
nodeId: string,
widgetName: string
): Promise<number> {
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.filter(([, name]) => name === widgetName).length
}
export async function getAllHostPromotedWidgets(
comfyPage: ComfyPage
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
const hostNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => String(node.id))
})
const entries = await Promise.all(
hostNodeIds.map(async (hostNodeId) => ({
hostNodeId,
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
}))
)
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
}

View File

@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 50 }
{ clip: hudClip, maxDiffPixels: 100 }
)
})
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 50 }
{ clip: hudClip, maxDiffPixels: 100 }
)
})
}

View File

@@ -157,6 +157,13 @@ test.describe('Signin dialog', () => {
})
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
await comfyPage.page.route('**/customers', (route) =>
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
})
)
const dialog = new SignInDialog(comfyPage.page)
const { result: dialogResult } = await dialog.openWithResult()

View File

@@ -7,9 +7,14 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_NAME = 'group_node'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -18,22 +23,19 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
@@ -41,9 +43,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getNode(GROUP_NODE_NAME).click()
// Verify the node is added to the canvas
await expect
@@ -52,9 +53,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
@@ -63,13 +64,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([groupNodeBookmarkName])
.toEqual([GROUP_NODE_BOOKMARK])
// Verify the bookmark node with the same name is added to the tree
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
// Unbookmark the node
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
await comfyPage.page
@@ -96,72 +96,57 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
})
})
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
test('Can be added to canvas using search', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
.toHaveLength(2)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const pos = await groupNode.getPosition()
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
})
return await node2.convertToGroupNode(name)
}
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const group1 = await makeGroup(
'g1',
'CLIPTextEncode',
'CheckpointLoaderSimple'
)
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
const manage1 = await group1.manageGroupNode()
const manage = await groupNode.manageGroupNode()
await comfyPage.nextFrame()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).toBeHidden()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
await manage.close()
await expect(manage.root).toBeHidden()
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
@@ -201,42 +186,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
.toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
await expect.poll(() => input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
await expect.poll(() => input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
@@ -249,11 +198,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
@@ -282,10 +226,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
await groupNode.copy()
})
@@ -299,10 +243,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
// Set setting
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
@@ -342,24 +283,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
})
wstest(
'Will not use stale litegraph previews',
async ({ comfyPage, getWebSocket }) => {
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview Image')
async function getNodeOutput() {
return await comfyPage.page.evaluate(
() => graph!.getNodeById('1')!.images?.[0]?.filename
)
}
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
await expect.poll(getNodeOutput).toBe('test1.png')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const resolvableFile = { filename: 'example.png', type: 'input' }
executionHelper.executed('', '1', { images: [resolvableFile] })
await expect.poll(getNodeOutput).toBe('example.png')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await node.imagePreview.hover()
await node.imagePreview
.getByRole('button', { name: 'Edit or mask image' })
.click()
// On previous versions, attempting to open the mask editor here would
// incorrectly reference the non-existant test1.png
// This causes the mask editor to throw in setup and not display
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
}
)

View File

@@ -9,7 +9,7 @@ test.describe(
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')

View File

@@ -46,15 +46,8 @@ test.describe(
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()

View File

@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -3,36 +3,40 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
test(
'Price badge displays on subgraphs',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -35,23 +35,6 @@ test.describe(
'add-group-group-added.png'
)
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.canvasOps.rightClick()
await comfyPage.contextMenu.clickMenuItem(
'Convert to Group Node (Deprecated)'
)
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -40,49 +40,19 @@ test.describe(
)
const [nodeId1, nodeId2] = nodeIds
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = (nodeId: string) =>
comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId1).fill('subgraph1_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId2).fill('subgraph2_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -1,38 +1,43 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Cleanup Behavior After Promoted Source Removal',
{ tag: ['@vue-nodes'] },
() => {
test('Deleting the promoted source removes the exterior promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.vueNodes.enterSubgraph('11')
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
}
)
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({

View File

@@ -34,49 +34,43 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget'] },
{ tag: ['@widget', '@vue-nodes'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
const OUTER_NODE_ID = '4'
const INNER_SUBGRAPH_NODE_ID = '3'
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyExpect(async () => {
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 outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(outerWidgets).toHaveCount(1)
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const exposedTextWidget = outerNode.getByRole('textbox', {
name: 'text'
})
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
}).toPass({ timeout: 5_000 })
const innerNode = comfyPage.vueNodes.getNodeLocator(
INNER_SUBGRAPH_NODE_ID
)
await comfyExpect(innerNode).toBeVisible()
const innerTextboxes = innerNode.getByRole('textbox')
await comfyExpect(innerTextboxes).toHaveCount(2)
const innerValues = await innerTextboxes.evaluateAll<
string[],
HTMLInputElement
>((boxes) => boxes.map((b) => b.value))
comfyExpect(innerValues).toContain('11111111111')
comfyExpect(innerValues).toContain('22222222222')
})
}
)
@@ -96,7 +90,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
@@ -129,7 +122,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeAfter).toBeVisible()
@@ -176,7 +168,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await comfyExpect(outerNode).toBeVisible()
@@ -210,7 +201,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -231,7 +221,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -250,7 +239,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
@@ -268,7 +256,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
@@ -279,7 +266,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)

View File

@@ -61,15 +61,12 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
@@ -78,7 +75,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -86,7 +82,6 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
@@ -95,88 +90,73 @@ test.describe(
})
})
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Promoted Widget Visibility in Vue Mode',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
})
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
const enterButton = subgraphVueNode.getByTestId(
'subgraph-enter-button'
)
await expect(enterButton).toBeVisible()
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
}
)
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
test.fail(
'Promoted and interior widgets stay in sync across navigation',
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
})
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await promotedTextarea.fill(testContent)
test.describe('Promoted Widget Reactivity', () => {
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.enterSubgraph('11')
const testContent = 'promoted-value-sync-test'
const interiorTextarea = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox', { name: 'text' })
.first()
await expect(interiorTextarea).toHaveValue(testContent)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.exitViaBreadcrumb()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveValue(updatedInteriorContent)
}
)
})
test.describe('Manual Promote/Demote via Context Menu', () => {
@@ -195,7 +175,6 @@ test.describe(
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
@@ -204,10 +183,8 @@ test.describe(
await promoteEntry.click()
await expect(promoteEntry).toBeHidden()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
@@ -216,7 +193,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -232,7 +208,6 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
// Wait for the context menu to close, confirming the action completed.
await expect(promoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -280,11 +255,9 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
@@ -317,8 +290,6 @@ test.describe(
'subgraphs/subgraph-with-preview-node'
)
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
@@ -331,7 +302,6 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -339,7 +309,6 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(
comfyPage,
@@ -356,7 +325,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
@@ -393,56 +361,48 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
await expect
.poll(async () => {
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
return disabledState.find((w) => w.name === 'string_a')?.disabled
})
.toBe(true)
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
test.describe(
'Nested Promoted Widget Disabled State',
{ tag: ['@vue-nodes'] },
() => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
const linkedTextarea = subgraphNode.getByRole('textbox', {
name: 'string_a',
exact: true
})
await expect(linkedTextarea).toBeVisible()
await expect(linkedTextarea).toBeDisabled()
const allTextareas = subgraphNode.getByRole('textbox')
await expect(allTextareas.first()).toBeVisible()
let editedTextarea = false
const count = await allTextareas.count()
for (let i = 0; i < count; i++) {
const textarea = allTextareas.nth(i)
if (await textarea.isEditable()) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
}
expect(editedTextarea).toBe(true)
})
})
expect(editedTextarea).toBe(true)
})
}
)
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
@@ -452,16 +412,13 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promotions exist
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
.toEqual(expect.arrayContaining([expect.anything()]))
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
// Node no longer exists, so promoted widgets should be gone
await expect.poll(() => subgraphNode.exists()).toBe(false)
})
@@ -520,17 +477,13 @@ test.describe(
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.removeSlot('input', 'text')
// Navigate back via breadcrumb
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
@@ -588,7 +541,6 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
@@ -608,216 +560,198 @@ test.describe(
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
test(
'Promote/Demote by Context Menu',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
}
)
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
test(
'Properties panel operations',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await expect(subgraph.content).toHaveCount(1)
})
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps, 'Un-promote widget').toBeHidden()
}
)
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})
test(
'Link already promoted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
}
)
test(
'Can promote multiple previews',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
}
)
test(
'Linked widgets can not be demoted',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.ensureOpen(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
}
)

View File

@@ -5,8 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
@@ -31,133 +29,125 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
test.describe(
'Subgraph Promotion DOM',
{ tag: ['@subgraph', '@vue-nodes'] },
() => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await openSubgraphById(comfyPage, '11')
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget preserves content through subgraph enter/exit', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await openSubgraphById(comfyPage, '11')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
await comfyPage.keyboard.press('Escape')
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const backToPromoted = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(backToPromoted).toBeVisible()
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
})
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
test('Promoted text widget is removed when subgraph node is deleted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
await openSubgraphById(comfyPage, '11')
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNodeRef.delete()
await comfyPage.subgraph.removeSlot('input', 'text')
await expect(subgraphNode).toHaveCount(0)
})
await comfyPage.subgraph.exitViaBreadcrumb()
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.exitViaBreadcrumb()
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(2)
const parentCount = await visibleWidgets.count()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await openSubgraphById(comfyPage, '11')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextareas = subgraphNode.getByRole('textbox')
await expect(promotedTextareas).toHaveCount(2)
await expect(visibleWidgets).toHaveCount(parentCount)
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.exitViaBreadcrumb()
const interiorTextareas = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox')
await expect(interiorTextareas).toHaveCount(2)
await expect(visibleWidgets).toHaveCount(parentCount)
})
})
})
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
).toHaveCount(2)
})
}
)
}
)

View File

@@ -14,6 +14,87 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
'subgraphs/subgraph-primitive-fanout-multi-host'
const UNRESOLVABLE_PROXY_WORKFLOW =
'subgraphs/subgraph-unresolvable-proxy-widget'
interface HostWidgetSnapshot {
name: string
sourceNodeId: string | null
sourceWidgetName: string | null
value: unknown
}
interface PrimitiveFanoutSnapshot {
hostWidgetNames: string[]
hostWidgetValues: HostWidgetSnapshot[]
interiorWidgetValues: unknown[]
primitiveOutputLinks: unknown
primitiveOriginLinkCount: number
serializedProperties: Record<string, unknown>
}
async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
const [primitiveNode] = hostNode.subgraph.findNodesByType(
'PrimitiveNode',
[]
)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === primitiveNode?.id).length
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return {
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
name: widget.name,
sourceNodeId:
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
? widget.sourceNodeId
: null,
sourceWidgetName:
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
? widget.sourceWidgetName
: null,
value: widget.value
})),
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
(node.widgets ?? []).map((widget) => widget.value)
),
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<Record<string, unknown>> {
return comfyPage.page.evaluate((id) => {
const serialized = window.app!.graph!.serialize()
const node = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return node?.properties ?? {}
}, hostNodeId)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -41,23 +122,173 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
await comfyPage.subgraph.serializeAndReload()
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(beforeReload.hostWidgetNames).toContain('value')
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgetErrorQuarantine'
)
await comfyPage.subgraph.serializeAndReload()
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(afterReload.interiorWidgetValues).toEqual(
beforeReload.interiorWidgetValues
)
expect(
afterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe(
beforeReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
)
expect(afterReload.primitiveOriginLinkCount).toBe(0)
expect(afterReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
}
)
test(
'Multiple SubgraphNode hosts keep independent migrated widget values',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
)
const expectHostHasIndependentValues = async (
hostId: string,
stringValue: string,
intValue: string
) => {
const host = comfyPage.vueNodes.getNodeLocator(hostId)
const widgets = host.getByTestId(TestIds.widgets.widget)
await expect(widgets).toHaveCount(2)
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
stringValue
)
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
intValue
)
}
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
await comfyPage.subgraph.serializeAndReload()
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
}
)
test(
'Nested preview exposures render through serialized chain resolution',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
test.setTimeout(45_000)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'8'
)
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
expect(nestedHostProperties.previewExposures).toEqual([
expect.objectContaining({
sourceNodeId: '6',
sourcePreviewName: '$$canvas-image-preview'
})
])
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(nestedSubgraphNode).toBeVisible()
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
}
)
test(
'Legacy unresolvable proxy entry is omitted and quarantined on save',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host).toBeVisible()
await expect(host.getByText('missing_widget')).toHaveCount(0)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
.not.toContain('missing_widget')
const serializedProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'2'
)
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'missing_widget'],
reason: 'missingSourceNode',
hostValue: 'quarantined-host-value'
})
])
}
)
test(
'Promoted widget remains usable after serialize and reload',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforeReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(afterReload).toBeVisible()
}
)
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
@@ -413,39 +644,10 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget'] },
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
@@ -466,7 +668,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
@@ -482,19 +683,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
await expect(widgetRows.first()).not.toContainText('6: 3:')
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
})
}
)

View File

@@ -507,25 +507,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
.toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
await expect
.poll(async () => {
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
return groupNodes.length
})
.toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {

View File

@@ -111,12 +111,10 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
.toBe(1)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
test('displays control widgets with default state', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Int')
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
await expect(widget).toBeVisible()
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
await expect(valueControl).toBeVisible()
})
})

View File

@@ -9,22 +9,23 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push(node.widgets![0])
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = node.widgets![0]
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, node.widgets![0])
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
})
@@ -52,4 +53,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
await test.step('Update value of the dynamic combo widget', async () => {
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

@@ -285,11 +285,11 @@ quarantine.
## PromotionStore
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
`PromotionStore` has been removed. Canonical value-widget exposure is
represented by linked `SubgraphInput`s. Canonical preview exposure is
represented by host-scoped `properties.previewExposures` /
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
and must not be reintroduced as runtime authority.
## Considered options
@@ -325,4 +325,5 @@ for existing workflow consumers that still assume array order.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` has a clear removal path.
- `PromotionStore` is removed; callers query linked inputs or preview exposures
directly.

View File

@@ -6,16 +6,17 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Six stores extract entity state out of class instances into centralized, queryable registries:
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -99,62 +100,39 @@ graph LR
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
## 3. PromotionStore
## 3. Linked promoted widgets and preview exposures
**File:** `src/stores/promotionStore.ts`
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
by linked `SubgraphInput`s, and display-only previews are represented by
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
Legacy `properties.proxyWidgets` is load-time migration input only.
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
### Runtime shape
### State Shape
```diagram
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
```
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
│ │ │
graphId subgraphNodeId ordered promotion entries
graphRefCounts: Map<UUID, Map<string, number>>
│ │ │
graphId entryKey count of nodes promoting this widget
╭────────────────╮ ╭──────────────────────╮
│ Subgraph host │────▶│ PreviewExposureStore │
╰────────────────╯ ╰──────────────────────╯
```
### Ref-Counting for O(1) Queries
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
```ts
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
// O(1) lookup: refCounts.get(key) > 0
```
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
### View Reconciliation Layer
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
```mermaid
graph LR
PS["PromotionStore
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
(proxy widget)"]
PV -->|"resolveDeepest()"| CW["Concrete Widget
(leaf node)"]
PV -->|"reads value"| WVS["WidgetValueStore"]
```
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
### ECS Alignment
| Aspect | ECS-like | Why |
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
| Ref-counted queries | Yes | Efficient global state queries without scanning |
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
## 4. LayoutStore (CRDT)
@@ -208,8 +186,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
@@ -222,7 +200,7 @@ graph TD
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, isPromotedByAny)"]
(getWidget, preview exposures)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
(LayoutStore)"]
@@ -249,13 +227,12 @@ graph TD
Each store invents its own identity scheme:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
@@ -289,7 +266,6 @@ graph TD
- value → WidgetValueStore
- label → WidgetValueStore
- disabled → WidgetValueStore
- promotion status → PromotionStore
- DOM pos/vis → DomWidgetStore"]
W_rem["Remains on class:
- _node back-ref
@@ -333,7 +309,8 @@ graph TD
subgraph Subgraph["Subgraph (node component)"]
S_ext["Extracted:
- promotions → PromotionStore"]
- value exposure → linked inputs
- preview exposure → PreviewExposureStore"]
S_rem["Remains on class:
- name, description
- inputs[], outputs[]
@@ -360,15 +337,15 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction

View File

@@ -250,5 +250,5 @@ interactions (e.g., `comfyPage.settings.setSetting`, `comfyPage.nodeOps`,
```bash
pnpm test:browser:local # Run all E2E tests
pnpm test:browser:local -- --ui # Interactive UI mode
pnpm test:browser:local --ui # Interactive UI mode
```

View File

@@ -30,7 +30,9 @@ See `docs/testing/*.md` for detailed patterns.
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
pnpm test:unit # Run all unit tests
pnpm test:unit path/to/file # Filter by substring of test file path
pnpm test:unit foo.test.ts -t "name" # Filter by test name (regex; it()/test() only, not describe())
```
Do not use the `--` separator before vitest args; pnpm forwards extra args automatically, and `--` mangles quoted args (e.g. `-t "two words"`) on Windows PowerShell.

View File

@@ -43,10 +43,10 @@ To run the tests locally:
pnpm test:unit
# Run a specific test file
pnpm test:unit -- src/path/to/file.test.ts
pnpm test:unit src/path/to/file.test.ts
# Run unit tests in watch mode
pnpm test:unit -- --watch
pnpm test:unit --watch
```
Refer to the specific guides for more detailed information on each testing type.

View File

@@ -373,7 +373,8 @@ export default defineConfig([
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}'
'src/workbench/**/*.{ts,vue}',
'src/world/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
@@ -401,6 +402,12 @@ export default defineConfig([
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/world/**',
from: './src/lib/litegraph/**',
message:
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.14",
"version": "1.46.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -206,8 +206,8 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x",
"pnpm": ">=11"
"node": ">=25",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.1.1"
"packageManager": "pnpm@11.3.0"
}

View File

@@ -406,9 +406,7 @@
--secondary-background-selected
);
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(
--color-alpha-charcoal-600-30
);
--component-node-widget-background-disabled: var(--color-charcoal-800);
--component-node-widget-background-highlighted: var(--color-smoke-800);
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-600);

View File

@@ -8,7 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -47,26 +48,8 @@ const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
const resolvedInputs = useResolvedSelectedInputs()
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
canRename: true
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
@@ -74,16 +57,6 @@ 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] {
@@ -102,22 +75,26 @@ function getHovered(
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
function getNodeBounding(nodeId: NodeId) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
if (!widget) return
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
}
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (entry.status !== 'resolved') return undefined
const { node, widget } = entry
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
@@ -133,6 +110,11 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
@@ -157,14 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -173,7 +152,7 @@ function nodeToDisplayTuple(
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
getNodeBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
@@ -191,10 +170,13 @@ const renderedOutputs = computed(() => {
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
)
)
</script>
<template>
@@ -238,30 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:can-rename="canRename"
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.widget.label ?? entry.displayName"
:sub-title="entry.node.title"
can-rename
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
@rename="renameWidget(entry.widget, entry.node, $event)"
/>
<IoItem
v-else
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
</DraggableList>
</PropertiesAccordionItem>
<div

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { computed, provide } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
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, NodeId } from '@/lib/litegraph/src/LGraphNode'
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'
@@ -23,15 +22,12 @@ 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 '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeId: NodeId
widgetName: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
@@ -49,31 +45,25 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing(
(nodeId, widgetName, config) =>
appModeStore.updateInputConfig(nodeId, widgetName, config)
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
appModeStore.updateInputConfig(widget, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
@@ -82,15 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
return vueWidget.entityId === entityId
})
if (!matchingWidget) return []
@@ -99,9 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: `${nodeId}:${widgetName}`,
nodeId,
widgetName,
key: entityId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -168,14 +148,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
nodeId,
widgetName,
persistedHeight,
nodeData,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -223,8 +196,7 @@ defineExpose({ handleDragDrop })
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
command: () => appModeStore.removeSelectedInput(action.widget)
}
]"
>
@@ -253,7 +225,7 @@ defineExpose({ handleDragDrop })
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

View File

@@ -1,11 +1,15 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
function setHeight(el: HTMLElement, height: number) {
Object.defineProperty(el, 'offsetHeight', {
value: height,
@@ -28,15 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
describe('useAppModeWidgetResizing', () => {
function setup() {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const { onPointerDown } = useAppModeWidgetResizing(onResize)
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
(e) => onPointerDown(widget, e as PointerEvent),
{ capture: true }
)
}
@@ -47,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
it('persists height when textarea is resized via drag', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('does not persist when no height change occurs (e.g. a click)', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -70,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
it('persists once per drag gesture; stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -86,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
const button = document.createElement('button')
wrapper.appendChild(button)
document.body.appendChild(wrapper)
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -104,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
wrapper.appendChild(indicator)
document.body.appendChild(wrapper)
setHeight(indicator, 100)
bind(wrapper, 1 as NodeId, 'image')
bind(wrapper, WIDGET_IMAGE)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(indicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
})
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
const { bind, onResize } = setup()
const first = wrapWithTextarea()
const second = wrapWithTextarea()
bind(first.wrapper, 1 as NodeId, 'prompt')
bind(second.wrapper, 2 as NodeId, 'other')
bind(first.wrapper, WIDGET_PROMPT)
bind(second.wrapper, WIDGET_OTHER)
first.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
@@ -132,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
})
it('treats pointercancel as the end of a gesture and persists the new height', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -159,14 +161,12 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const scope = effectScope()
const { onPointerDown } = scope.run(() =>
useAppModeWidgetResizing(onResize)
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
const { wrapper, textarea } = wrapWithTextarea()
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
{ capture: true }
)
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
outerIndicator.appendChild(wrapper)
document.body.appendChild(outerIndicator)
setHeight(outerIndicator, 100)
bind(wrapper, 1 as NodeId, 'prompt')
bind(wrapper, WIDGET_PROMPT)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(outerIndicator, 250)

View File

@@ -1,16 +1,12 @@
import { onScopeDispose } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
export function useAppModeWidgetResizing(
onResize: (
nodeId: NodeId,
widgetName: string,
config: InputWidgetConfig
) => void
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
) {
let pendingHandler: (() => void) | null = null
@@ -23,11 +19,7 @@ export function useAppModeWidgetResizing(
onScopeDispose(clearPendingHandler)
function onPointerDown(
nodeId: NodeId,
widgetName: string,
event: PointerEvent
) {
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
const wrapper = event.currentTarget
const target = event.target
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
@@ -44,7 +36,7 @@ export function useAppModeWidgetResizing(
pendingHandler = null
const height = resizable.offsetHeight
if (height === startHeight) return
onResize(nodeId, widgetName, { height })
onResize(widget, { height })
}
pendingHandler = handler
window.addEventListener('pointerup', handler)

View File

@@ -0,0 +1,91 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
id: '11111111-1111-4111-8111-111111111111',
nodes: [] as LGraphNode[],
events: new EventTarget(),
getNodeById: vi.fn() as (id: number) => LGraphNode | null
}
}
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
}))
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
)
}
function dispatchRootGraphEvent(type: string) {
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
new Event(type)
)
}
describe('useResolvedSelectedInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
setRootGraphNodes([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('convert-to-subgraph')
expect(resolved.value[0]?.status).toBe('unknown')
})
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('subgraph-created')
expect(resolved.value[0]?.status).toBe('unknown')
})
})

View File

@@ -0,0 +1,71 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
config?: InputWidgetConfig
}
| {
status: 'unknown'
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
export function useResolvedSelectedInputs() {
const appModeStore = useAppModeStore()
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
const refreshGraphNodes = () =>
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
useEventListener(
() => app.rootGraph?.events,
'convert-to-subgraph',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'subgraph-created',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'node:slot-label:changed',
() => triggerRef(graphNodes)
)
return computed<ResolvedSelection[]>(() => {
void graphNodes.value
const rootGraph = app.rootGraph
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)
})
}

View File

@@ -71,6 +71,7 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -11,17 +11,39 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i class="pi pi-camera text-lg text-base-foreground" />
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
0
)
expect(
screen.getByText(
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'

View File

@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/i18n', () => ({
te: vi.fn(() => false),
st: vi.fn((_key: string, fallback: string) => fallback),
t: vi.fn((key: string, params?: { count?: number }) => {
if (key === 'errorOverlay.missingModels') {
const count = params?.count ?? 0
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return key
})
}))
vi.mock('@/i18n', () => {
const messages: Record<string, string> = {
'errorCatalog.validationErrors.required_input_missing.title':
'Missing connection',
'errorCatalog.validationErrors.required_input_missing.message':
'Required input slots have no connection feeding them.',
'errorCatalog.validationErrors.required_input_missing.details':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.required_input_missing.itemLabel':
'{nodeName} - {inputName}',
'errorCatalog.validationErrors.required_input_missing.toastTitle':
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
const interpolate = (
message: string,
params?: Record<string, string | number>
) =>
message.replace(/\{(\w+)\}/g, (match, paramName) =>
params?.[paramName] === undefined ? match : String(params[paramName])
)
return {
te: vi.fn((key: string) => key in messages),
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
t: vi.fn((key: string, params?: Record<string, string | number>) => {
if (key === 'errorOverlay.missingModels') {
const count = Number(params?.count ?? 0)
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return interpolate(messages[key] ?? key, params)
})
}
})
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
'KSampler failed'
)
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
})
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
// bypass catalog display fields until targeted runtime handling lands.
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
})
it('includes prompt error when present', async () => {
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
})

View File

@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const resolvedDisplay = resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: e.node_type,
isCloud
})
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolvedDisplay
exceptionType: e.exception_type
}
],
filterBySelection

View File

@@ -1,14 +1,24 @@
<script setup lang="ts">
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useMounted, watchDebounced } from '@vueuse/core'
import {
computed,
inject,
onBeforeUnmount,
provide,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -54,12 +64,74 @@ const {
const collapse = defineModel<boolean>('collapse', { default: false })
const emit = defineEmits<{
reorder: [event: { fromIndex: number; toIndex: number }]
}>()
const widgetsContainer = ref<HTMLElement>()
const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
const draggableList = ref<DraggableList | undefined>()
const isMounted = useMounted()
function setDraggableState() {
draggableList.value?.dispose()
draggableList.value = undefined
if (!isMounted.value || !isDraggable || collapse.value) return
const container = widgetsContainer.value
if (!container?.children?.length) return
const list = new DraggableList(container, '.draggable-item')
list.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[SectionWidgets] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
if (typeof reorderedItems[index] === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
emit('reorder', { fromIndex: oldPosition, toIndex: newPosition })
}
draggableList.value = list
}
watchDebounced(
[widgets, () => isDraggable, collapse],
() => setDraggableState(),
{ debounce: 100, immediate: true }
)
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
@@ -70,8 +142,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -83,13 +153,12 @@ function isWidgetShownOnParents(
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})

View File

@@ -1,18 +1,9 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef
} from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -25,8 +16,6 @@ const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { t } = useI18n()
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
const isSearching = ref(false)
const favoritedWidgets = computed(
@@ -48,70 +37,20 @@ async function searcher(query: string) {
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
}
const isMounted = useMounted()
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = [...searchedFavoritedWidgets.value]
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
const widgets = [...searchedFavoritedWidgets.value]
const [widget] = widgets.splice(oldPosition, 1)
widgets.splice(newPosition, 0, widget)
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
}
}
watchDebounced(
searchedFavoritedWidgets,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
function onCollapseUpdate() {
// Rebuild draggable list after the section header is toggled
nextTick(setDraggableState)
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
}
</script>
@@ -127,7 +66,6 @@ function onCollapseUpdate() {
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:label
:widgets="searchedFavoritedWidgets"
:is-draggable="!isSearching"
@@ -135,7 +73,7 @@ function onCollapseUpdate() {
show-node-name
enable-empty-state
class="border-b border-interface-stroke"
@update:collapse="onCollapseUpdate"
@reorder="handleReorder"
>
<template #empty>
<div class="px-4 py-10 text-center text-sm text-muted-foreground">

View File

@@ -1,26 +1,19 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useTemplateRef,
watch
} from 'vue'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -33,7 +26,6 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -51,13 +43,33 @@ const isAllCollapsed = computed({
advancedInputsCollapsed.value = collapse
}
})
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
@@ -81,37 +93,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
const entries = promotionEntries.value
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
}
}
return result
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {
@@ -126,12 +108,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
})
)
})
@@ -146,66 +125,22 @@ async function searcher(query: string) {
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
}
const isMounted = useMounted()
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = searchedWidgetsList.value.map((row) => row.widget)
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[TabSubgraphInputs] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
canvasStore.canvas?.setDirty(true, true)
}
reorderSubgraphInputsByWidgetOrder(node, widgets)
canvasStore.canvas?.setDirty(true, true)
}
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
debounce: 100
})
onMounted(() => setDraggableState())
onBeforeUnmount(() => draggableList.value?.dispose())
const label = computed(() => {
return searchedWidgetsList.value.length !== 0
? t('rightSidePanel.inputs')
@@ -229,7 +164,6 @@ const label = computed(() => {
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:collapse="firstSectionCollapsed && !isSearching"
:node
:label
@@ -243,12 +177,8 @@ const label = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="
(v) => {
firstSectionCollapsed = v
nextTick(setDraggableState)
}
"
@update:collapse="(v) => (firstSectionCollapsed = v)"
@reorder="handleReorder"
>
<template #empty>
<div class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground">

View File

@@ -9,15 +9,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
demoteWidget: vi.fn(),
promoteWidget: vi.fn(),
isLinkedPromotion: vi.fn(() => false)
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: mockGetInputSpecForWidget
@@ -201,64 +205,4 @@ describe('WidgetActions', () => {
expect(onResetToDefault).toHaveBeenCalledWith('option1')
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
})
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
})
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const user = userEvent.setup()
render(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
await user.click(screen.getByRole('button', { name: /Hide input/ }))
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -43,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -82,16 +79,19 @@ function handleHideInput() {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -42,7 +42,7 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'

View File

@@ -0,0 +1,265 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
type DraggableListProps = ComponentProps<typeof DraggableList>
type PromotedRow =
DraggableListProps['modelValue'] extends Array<infer T> ? T : never
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subgraphStore: {
shown: 'Shown',
hidden: 'Hidden',
hideAll: 'Hide all',
showAll: 'Show all',
addRecommended: 'Add recommended'
},
rightSidePanel: {
noneSearchDesc: 'No results'
},
g: {
search: 'Search',
searchPlaceholder: 'Search'
}
}
}
})
describe('SubgraphEditor', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('renders preview exposures after promoted inputs without drag handles', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.add(previewNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
usePreviewExposureStore().addExposure(
subgraph.rootGraph.id,
String(host.id),
{
sourceNodeId: String(previewNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second', '$$canvas-image-preview'])
expect(
within(screen.getByTestId('draggable-list'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
expect(
within(shown).getAllByTestId('subgraph-widget-drag-handle')
).toHaveLength(2)
})
it('updates rendered order when promoted widgets are reordered', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
let listSetter: ((value: PromotedRow[]) => void) | undefined
const draggableListStub = {
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
_: unknown,
{
emit,
slots
}: {
emit: (event: string, ...args: unknown[]) => void
slots: { default?: (props: { dragClass: string }) => unknown }
}
) {
listSetter = (value) => emit('update:modelValue', value)
return () => slots.default?.({ dragClass: 'draggable-item' })
}
}
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: { DraggableList: draggableListStub }
}
})
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['second', 'first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const orphanedSourceNode = new LGraphNode('OrphanedNode')
orphanedSourceNode.type = 'OrphanedNode'
subgraph.add(orphanedSourceNode)
const previewStore = usePreviewExposureStore()
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
sourceNodeId: String(orphanedSourceNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(1)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const toggleButton = within(shown).getByTestId('subgraph-widget-toggle')
await userEvent.click(toggleButton)
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(0)
})
})

View File

@@ -1,106 +1,141 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
const { t } = useI18n()
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
}
type PreviewRow = {
kind: 'preview'
node: LGraphNode
exposure: PreviewExposure
realWidget?: IBaseWidget
}
type ActiveRow = PromotedRow | PreviewRow
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
return promotionStore.getPromotions(node.rootGraph.id, node.id)
})
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
return undefined
})
const activeWidgets = computed<WidgetItem[]>({
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
'widget-promoted',
'widget-demoted',
'input-added',
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
)
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
const node = activeNode.value
if (!node) return []
return promotionEntries.value.flatMap(
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
)
return node ? getActivePromotedRows(node) : []
},
set(value: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
refreshPromotedWidgetRendering()
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
return exposures.flatMap((exposure): PreviewRow[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
)
return [{ kind: 'preview', node: sourceNode, exposure, realWidget }]
})
}
function updateActivePromotedRows(
value: PromotedRow[],
currentItems: PromotedRow[]
) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
const currentKeys = new Set(currentItems.map(promotedRowKey))
const nextKeys = new Set(value.map(promotedRowKey))
for (const item of value) {
if (!currentKeys.has(promotedRowKey(item))) promotePromotedRow(item)
}
for (const item of currentItems) {
if (!nextKeys.has(promotedRowKey(item))) demoteRow(item)
}
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
)
}
refreshPromotedWidgetRendering()
}
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
@@ -115,18 +150,18 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -145,16 +180,31 @@ const recommendedWidgets = computed(() => {
return filteredCandidates.value.filter(isRecommendedWidget)
})
const filteredActive = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
function rowMatchesQuery(row: ActiveRow, query: string): boolean {
return (
row.node.title.toLowerCase().includes(query) ||
rowDisplayName(row).toLowerCase().includes(query)
)
}
const filteredActive = computed<ActiveRow[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeRows.value
return activeRows.value.filter((row) => rowMatchesQuery(row, query))
})
const filteredActivePromoted = computed<PromotedRow[]>(() =>
filteredActive.value.filter(
(row): row is PromotedRow => row.kind === 'promoted'
)
)
const filteredActivePreviews = computed<PreviewRow[]>(() =>
filteredActive.value.filter(
(row): row is PreviewRow => row.kind === 'preview'
)
)
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
@@ -164,57 +214,89 @@ function refreshPromotedWidgetRendering() {
canvasStore.canvas?.setDirty(true, true)
}
function isItemLinked([node, widget]: WidgetItem): boolean {
function rowDisplayName(row: ActiveRow): string {
if (row.kind === 'promoted') {
return row.widget.label || row.widget.name
}
return (
node.id === -1 ||
(!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(node.id),
getWidgetName(widget)
))
row.realWidget?.label ||
row.realWidget?.name ||
row.exposure.sourcePreviewName
)
}
function toKey(item: WidgetItem) {
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
}
function rowKey(row: ActiveRow): string {
return row.kind === 'promoted'
? promotedRowKey(row)
: `${row.node.id}: ${row.exposure.name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
}
function demote([node, widget]: WidgetItem) {
function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
return
}
if (row.realWidget) {
demoteWidget(row.node, row.realWidget, [subgraphNode])
return
}
previewExposureStore.removeExposure(
subgraphNode.rootGraph.id,
String(subgraphNode.id),
row.exposure.name
)
refreshPromotedWidgetRendering()
}
function promote([node, widget]: WidgetItem) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
}
function showAll() {
for (const item of filteredCandidates.value) {
promote(item)
promoteCandidate(item)
}
}
function hideAll() {
const node = activeNode.value
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
if (
node &&
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
)
continue
demote(item)
for (const row of filteredActive.value) {
if (String(row.node.id) === '-1') continue
demoteRow(row)
}
}
function showRecommended() {
for (const item of recommendedWidgets.value) {
promote(item)
promoteCandidate(item)
}
}
@@ -259,19 +341,34 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<DraggableList v-slot="{ dragClass }" v-model="activePromotedRows">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:data-nodeid="node.id"
v-for="row in filteredActivePromoted"
:key="rowKey(row)"
:data-nodeid="row.node.id"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
is-shown
@toggle-visibility="demoteRow(row)"
/>
</DraggableList>
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="row in filteredActivePreviews"
:key="rowKey(row)"
:data-nodeid="row.node.id"
class="bg-comfy-menu-bg"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:is-draggable="false"
is-shown
@toggle-visibility="demoteRow(row)"
/>
</div>
</div>
<div
@@ -295,12 +392,12 @@ onMounted(() => {
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
:key="`${node.id}:${widget.name}`"
:data-nodeid="node.id"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
@toggle-visibility="promoteCandidate([node, widget])"
/>
</div>
</div>

View File

@@ -10,22 +10,22 @@ const {
widgetName,
isDraggable = false,
isPhysical = false,
isShown = false,
class: className
} = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
isPhysical?: boolean
isShown?: boolean
class?: ClassValue
}>()
defineEmits<{
(e: 'toggleVisibility'): void
}>()
defineEmits<{ toggleVisibility: [] }>()
const icon = computed(() =>
isPhysical
? 'icon-[lucide--link]'
: isDraggable
: isShown
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
)
@@ -65,6 +65,7 @@ const icon = computed(() =>
</Button>
<div
v-if="isDraggable"
data-testid="subgraph-widget-drag-handle"
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
/>
</div>

View File

@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] }
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template: '<div data-testid="search-content-v2"></div>'
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
})
const pinia = createTestingPinia({
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,6 +27,7 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -76,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -87,6 +90,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -102,6 +106,13 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -126,7 +137,6 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -141,8 +141,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters } = defineProps<{
const { filters, defaultRootFilter = null } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -193,8 +194,12 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -245,9 +245,7 @@ const MENU_ORDER: string[] = [
'Paste Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
'Paste (Clipspace)'
]
/**

View File

@@ -7,6 +7,7 @@ import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -16,7 +17,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
@@ -102,12 +102,15 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
return { graph, node }
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'STRING')
graph.add(upstream)
const link = upstream.connect(0, node, 0)
if (!link) throw new Error('Expected upstream.connect to produce a link')
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
@@ -187,7 +190,28 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'prompt', type: 'STRING' }]
})
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(input, node)
if (!link)
throw new Error('Expected SubgraphInput.connect to produce a link')
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -207,7 +231,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -218,7 +241,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
@@ -229,21 +251,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
@@ -403,37 +411,6 @@ describe('Subgraph output slot label reactivity', () => {
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = vueNode?.widgets?.find(
(widget) => widget.name === '$$canvas-image-preview'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -471,122 +448,49 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
inputs: [
{ name: 'first_seed', type: '*' },
{ name: 'second_seed', type: '*' }
]
})
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined)
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined)
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
})
})

View File

@@ -9,8 +9,10 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
@@ -23,9 +25,12 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
LGraph,
@@ -55,10 +60,9 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -154,9 +158,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
@@ -229,18 +231,15 @@ function safeWidgetMapper(
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
sourceWidgetName: resolvedTarget.widgetName
}
}
@@ -258,10 +257,9 @@ function safeWidgetMapper(
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource = {
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
@@ -308,8 +306,7 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
@@ -322,24 +319,21 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const storeName = isPromotedWidgetView(widget)
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
storeNodeId: nodeId,
name,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
@@ -387,10 +381,10 @@ function buildSlotMetadata(
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}

View File

@@ -45,8 +45,7 @@ export interface SubMenuOption {
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
NEW = 'new'
}
// Global singleton for NodeOptions component reference

View File

@@ -72,14 +72,14 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
it('does not include a Convert to Group Node option', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
expect(groupNodeOption).toBeUndefined()
})
})

View File

@@ -1,8 +1,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useFrameNodes } from './useFrameNodes'
import { BadgeVariant } from './useMoreOptionsMenu'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -102,28 +100,13 @@ export function useSelectionMenuOptions() {
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {
const convertToGroupNodes = () => {
const commandStore = useCommandStore()
void commandStore.execute(
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
)
const getMultipleNodesOptions = (): MenuOption[] => [
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
return [
{
label: t('contextMenu.Convert to Group Node'),
icon: 'icon-[lucide--group]',
action: convertToGroupNodes,
badge: BadgeVariant.DEPRECATED
},
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
]
}
]
const getAlignmentOptions = (): MenuOption[] => [
{

View File

@@ -1,7 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'KSampler',
'KSamplerAdvanced',
'PreviewImage',
'SaveImage',
'GLSLShader'

View File

@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
const buildJsonataContext = (
node: LGraphNode,
rule: JsonataPricingRule
rule: JsonataPricingRule,
widgetOverrides?: ReadonlyMap<string, unknown>
): JsonataEvalContext => {
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of rule.depends_on.widgets) {
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
const raw = widgetOverrides?.has(dep.name)
? widgetOverrides.get(dep.name)
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
}
const inputs: Record<string, { connected: boolean }> = {}
@@ -552,7 +555,10 @@ export const useNodePricing = () => {
* - schedules async evaluation when needed
* - remains non-fatal on errors (returns safe fallback '')
*/
const getNodeDisplayPrice = (node: LGraphNode): string => {
const getNodeDisplayPrice = (
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): string => {
// Make this function reactive: when async evaluation completes, we bump pricingTick,
// which causes this getter to recompute in Vue render/computed contexts.
void pricingTick.value
@@ -565,7 +571,7 @@ export const useNodePricing = () => {
if (rule.engine !== 'jsonata') return ''
if (!rule._compiled) return ''
const ctx = buildJsonataContext(node, rule)
const ctx = buildJsonataContext(node, rule, widgetOverrides)
const sig = buildSignature(ctx, rule)
const cached = cache.get(node)

View File

@@ -1,6 +1,8 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -9,14 +11,25 @@ componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const nodePricing = useNodePricing()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
if (innerCreditsBadges.length > 1) {
node.badges.push(
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
)
} else if (innerCreditsBadges.length === 1) {
const innerApiNodes = collectInnerApiNodes(node.subgraph)
// When a single inner api node is the price source, swap its static
// getter for a wrapper-aware one that resolves promoted widget values.
if (innerApiNodes.length === 1) {
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
} else {
node.badges.push(...innerCreditsBadges)
}
}
const graph = node.graph
if (!graph) return
@@ -28,13 +41,14 @@ export const usePriceBadge = () => {
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges = []
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
@@ -45,6 +59,51 @@ export const usePriceBadge = () => {
return badges
}
function collectInnerApiNodes(
graph: LGraph,
visited: Set<string> = new Set()
): LGraphNode[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const apiNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode()) {
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
} else if (node.constructor?.nodeData?.api_node) {
apiNodes.push(node)
}
}
return apiNodes
}
function buildWrapperAwarePriceBadge(
wrapper: LGraphNode,
innerNode: LGraphNode
): () => LGraphBadge {
return () =>
getCreditsBadge(
nodePricing.getNodeDisplayPrice(
innerNode,
collectPromotedOverrides(wrapper, innerNode)
)
)
}
function collectPromotedOverrides(
wrapper: LGraphNode,
innerNode: LGraphNode
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {

View File

@@ -9,35 +9,39 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
| 'nodeOutputs'
| 'nodePreviewImages'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
| 'getNodeOutputByExecutionId'
| 'getNodePreviewImagesByExecutionId'
>
const getNodeImageUrls = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
)
const useNodeOutputStoreMock = vi.hoisted(() =>
vi.fn<() => MockNodeOutputStore>()
)
vi.mock('@/stores/nodeOutputStore', () => {
return {
useNodeOutputStore: useNodeOutputStoreMock
}
})
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
const store: MockNodeOutputStore = {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
getNodeImageUrls: vi.fn(),
getNodeImageUrlsByExecutionId: vi.fn(),
getNodeOutputByExecutionId: vi.fn(),
getNodePreviewImagesByExecutionId: vi.fn()
}
return { useNodeOutputStore: () => store }
})
function clearMockNodeOutputStore() {
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
for (const key of Object.keys(nodePreviewImages))
delete nodePreviewImages[key]
}
function createSetup() {
@@ -83,16 +87,43 @@ function seedPreviewImages(
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
interface ArrangeOptions {
id?: number
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
urls?: string[]
}
function arrangePromotedPreview(options: ArrangeOptions = {}) {
const {
id = 10,
previewMediaType,
urls = ['/view?filename=output.png']
} = options
const setup = createSetup()
addInteriorNode(setup, { id, previewMediaType })
exposePreview(setup, String(id))
seedOutputs(setup.subgraph.id, [id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
return { setup, urls }
}
describe(usePromotedPreviews, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
vi.resetAllMocks()
clearMockNodeOutputStore()
})
it('returns empty array for non-SubgraphNode', () => {
@@ -109,70 +140,50 @@ describe(usePromotedPreviews, () => {
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('returns image preview for promoted $$ widget with outputs', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const { setup, urls } = arrangePromotedPreview({
previewMediaType: 'image'
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
urls
}
])
})
it('returns video type when interior node has video previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
it.for([
['video', '/view?filename=output.webm'],
['audio', '/view?filename=output.mp3']
] as const)(
'returns %s type when interior node has %s previewMediaType',
([mediaType, url]) => {
const { setup } = arrangePromotedPreview({
previewMediaType: mediaType,
urls: [url]
})
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe(mediaType)
}
)
it('defaults preview type to image when previewMediaType is unset', () => {
const { setup, urls } = arrangePromotedPreview()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
})
it('returns audio type when interior node has audio previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
expect(promotedPreviews.value).toEqual([
expect.objectContaining({ type: 'image', urls })
])
})
it('returns separate entries for multiple promoted $$ widgets', () => {
@@ -185,23 +196,17 @@ describe(usePromotedPreviews, () => {
id: 20,
previewMediaType: 'image'
})
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
exposePreview(setup, '20')
seedOutputs(setup.subgraph.id, [10, 20])
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -212,21 +217,17 @@ describe(usePromotedPreviews, () => {
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
@@ -236,23 +237,19 @@ describe(usePromotedPreviews, () => {
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
@@ -262,11 +259,7 @@ describe(usePromotedPreviews, () => {
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -274,36 +267,132 @@ describe(usePromotedPreviews, () => {
it('skips missing interior nodes', () => {
const setup = createSetup()
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '99')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('ignores non-$$ promoted widgets', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
it('renders leaf media exposed through a nested subgraph host', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
}
])
})
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
const outputStore = useNodeOutputStore()
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
vi.mocked(outputStore.getNodeImageUrlsByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:second']
}
])
})
})

View File

@@ -3,8 +3,9 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
@@ -14,65 +15,126 @@ interface PromotedPreview {
urls: string[]
}
/**
* Returns reactive preview media from promoted `$$` pseudo-widgets
* on a SubgraphNode. Each promoted preview interior node produces
* a separate entry so they render independently.
*/
const PREVIEW_TYPES_BY_MEDIA = {
video: 'video',
audio: 'audio'
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
const media = node.previewMediaType
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
}
return 'image'
}
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const nodeOutputStore = useNodeOutputStore()
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
leafHost.subgraph.id,
leafSourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
const reactiveExecutionOutputs =
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
const reactiveExecutionPreviews =
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
const hasAnySource =
reactiveOutputs?.images?.length ||
reactivePreviews?.length ||
reactiveExecutionOutputs?.images?.length ||
reactiveExecutionPreviews?.length
if (!hasAnySource) return undefined
return (
nodeOutputStore.getNodeImageUrlsByExecutionId(
leafExecutionId,
interiorNode
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
)
}
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
const exposures = previewExposureStore.getExposures(
rootGraphId,
hostLocator
)
if (!pseudoEntries.length) return []
if (!exposures.length) return []
const previews: PromotedPreview[] = []
const hostNodesByLocator = new Map<string, SubgraphNode>([
[hostLocator, node]
])
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
if (!(sourceNode instanceof SubgraphNode)) return undefined
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue
const type =
interiorNode.previewMediaType === 'video'
? 'video'
: interiorNode.previewMediaType === 'audio'
? 'audio'
: 'image'
previews.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
const definitionLocator = String(sourceNode.id)
const hasPathExposures =
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
const nestedHostLocator = hasPathExposures
? pathLocator
: definitionLocator
hostNodesByLocator.set(nestedHostLocator, sourceNode)
return { rootGraphId, hostNodeLocator: nestedHostLocator }
}
return previews
return exposures.flatMap((exposure): PromotedPreview[] => {
const resolved = previewExposureStore.resolveChain(
rootGraphId,
hostLocator,
exposure.name,
resolveNestedHost
)
const leaf = resolved?.leaf ?? {
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
const leafHostLocator =
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) return []
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []
return [
{
sourceNodeId: leaf.sourceNodeId,
sourceWidgetName: leaf.sourcePreviewName,
type: getPreviewMediaType(interiorNode),
urls
}
]
})
})
return { promotedPreviews }

View File

@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??

View File

@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -568,17 +569,21 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
state: null,
retainViewOnReload: true
})
})

View File

@@ -483,6 +483,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }

View File

@@ -1,122 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
function createHostWithInnerWidget(widgetName: string) {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'value', type: 'number' }]
})
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
innerSubgraph.add(innerNode)
innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const hostNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
return { rootGraph, innerSubgraph, innerNode, hostNode }
}
describe('normalizeLegacyProxyWidgetEntry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('returns entry unchanged when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
})
it('returns entry unchanged with disambiguator when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed',
String(innerNode.id)
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'number' }]
})
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph
})
outerSubgraph.add(nestedNode)
const hostNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(nestedNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('returns original entry when prefix cannot be resolved', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: '999: nonexistent_widget'
})
})
})

View File

@@ -1,111 +0,0 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
function canResolve(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
)
}
function tryResolveCandidate(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): PromotedWidgetPatch | undefined {
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
return undefined
return {
sourceWidgetName: widgetName,
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
}
}
function resolveLegacyPrefixedEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetPatch | undefined {
let remaining = sourceWidgetName
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return undefined
const [, legacySourceNodeId, unprefixed] = match
remaining = unprefixed
const disambiguators = [
legacySourceNodeId,
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
undefined
]
for (const disambiguator of disambiguators) {
const resolved = tryResolveCandidate(
hostNode,
sourceNodeId,
remaining,
disambiguator
)
if (resolved) return resolved
}
}
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const patch = resolveLegacyPrefixedEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const patchDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}

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