Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions
ceb601c823 Update locales 2026-05-28 00:27:23 +00:00
christian-byrne
da2d38dedf [release] Increment version to 1.46.4 2026-05-28 00:20:47 +00:00
Dante
5f2b2f2e87 fix: show cloud models in IC-LoRA Loader Model Only node (FE-838) (#12488)
## Summary

### before
<img width="1107" height="958" alt="before-buggy"
src="https://github.com/user-attachments/assets/1fcbd909-e008-4bd3-967f-87cdabb2baf6"
/>

### after
<img width="1107" height="958" alt="after-fixed"
src="https://github.com/user-attachments/assets/0d3c6f3f-36d6-4556-bd29-b3826ae20216"
/>


The **IC-LoRA Loader Model Only** node (`LTXICLoRALoaderModelOnly`, from
ComfyUI-LTXVideo) didn't show cloud models from `supported_models.json`,
while the native **Load LoRA** node did.

## Changes

- **What**: Add `['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']` to
`MODEL_NODE_MAPPINGS`. Whether a combo widget swaps to the cloud asset
browser is gated by `assetService.shouldUseAssetBrowser` →
`isAssetBrowserEligible`, which only returns true for node types
registered in `MODEL_NODE_MAPPINGS` (via `modelToNodeStore`). The custom
IC-LoRA loader was absent from that list, so its `lora_name` widget fell
back to the plain combo that lists only filesystem models — never the
cloud-injected ones.
- **Breaking**: none

## Review Focus

Root cause verified live on `cloud.comfy.org` (asset API enabled, custom
node installed) via CDP:
- `LoraLoaderModelOnly` (native) → registry `lora_name`, eligible `true`
→ cloud models shown
- `LTXICLoRALoaderModelOnly` (bug) → not in registry, eligible `false` →
cloud models missing
- After registering the mapping live → eligible `true`, category `loras`
→ cloud models shown

Same class of bug as FE-492 (custom loaders missing from the mapping);
long-term, auto-detecting model-folder-backed combos would remove the
need to register each custom loader by hand.

Fixes FE-838

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
64d099f6c](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26508637513)
| 🔴 Red (failure) | Proves the test catches the bug |
| [`fix:`
6b91a570d](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26509067631)
| 🟢 Green (success) | Proves the fix resolves it |

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit regression in `src/stores/modelToNodeStore.test.ts`
- [ ] E2E not applicable (custom node + cloud asset API not available in
CI)
2026-05-27 22:51:26 +00:00
Dante
a931acadd3 feat(dialog): migrate Settings dialog to Reka-UI (Phase 3) (#12182)
## Summary

Phase 3 of the dialog migration. Closes the parity gaps in the Reka
renderer (maximize affordance, headless layout mode, overlay-class
plumbing), then flips `useSettingsDialog` onto the Reka path. Public API
of `useDialogService` / `dialogStore` is unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-575](https://linear.app/comfyorg/issue/FE-575/phase-3-migrate-settings-dialog-workspace-non-workspace-designer)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, **stacked PR base**)

> **Stacked on Phase 2**: this PR targets
`jaewon/dialog-reka-migration-phase-2`. Rebase onto `main` after #12109
lands.

## Changes

### Reka primitives — parity gaps closed

| File | Change |
| --- | --- |
| `src/components/ui/dialog/dialog.variants.ts` | New `maximized`
variant. `false` keeps the centered/sized layout; `true` switches to
`inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none` for
full-screen mode |
| `src/components/ui/dialog/DialogContent.vue` | Accepts `maximized`
prop, forwards to variants |
| `src/components/ui/dialog/DialogMaximize.vue` **(new)** | Icon-only
button toggling `lucide--maximize-2` / `lucide--minimize-2`; emits
`toggle`; uses `g.maximizeDialog` / `g.restoreDialog` i18n |
| `src/stores/dialogStore.ts` | Adds `overlayClass?:
HTMLAttributes['class']` to `CustomDialogComponentProps` (Reka-only;
PrimeVue path uses `pt.mask`) |
| `src/components/dialog/GlobalDialog.vue` | (a) Forwards `overlayClass`
to `DialogOverlay`; (b) passes `:maximized` to `DialogContent`; (c)
renders `DialogMaximize` in the header when `maximizable`, wired to a
local `toggleMaximize`; (d) when `headless: true`, skips the inner
`flex-1 overflow-auto px-4 py-2` wrapper so layout dialogs control their
own chrome |

### Settings flip

| File | Change |
| --- | --- |
| `src/platform/settings/composables/useSettingsDialog.ts` | Adds
`dialogComponentProps: { renderer: 'reka', size: 'full', contentClass:
'\<...\>', overlayClass }`. `contentClass` is `w-[90vw] max-w-[960px]
sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden` —
matches the previous `BaseModalLayout size="sm"` (960px × 80vh).
`overlayClass: 'p-8'` only when `isCloud && teamWorkspacesEnabled`
(preserves the workspace breathing-room contract) |
| `src/components/dialog/GlobalDialog.vue` | Drops the now-dead
`getDialogPt` workspace special case and the orphan
`.settings-dialog-workspace` CSS. Removes unused imports (`merge`,
`computed`, `useFeatureFlags`, `isCloud`, `DialogPassThroughOptions`) |

### Tests

- `src/platform/settings/composables/useSettingsDialog.test.ts`
**(new)** — 5 tests: renderer flip + sizing, workspace `overlayClass`
toggle, panel forwarding, `showAbout()`

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `useSettingsDialog.test.ts` — 5/5
  - `dialogService.renderer.test.ts` — 5/5
  - `GlobalDialog.test.ts` — 9/9
  - All `src/components/dialog/` — 73/73
  - All `src/platform/settings/` — 75/75
  - `CustomizationDialog.test.ts` — 4/4
- [ ] CI Playwright matrix
- [ ] Manual verification on a backend

## Screenshots

End-to-end verification of the Reka flip on a local dev server:

| | |
| --- | --- |
| Settings dialog rendered via Reka (non-modal, focus stays in dialog
body) |
![Settings](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/settings-dialog-reka.png)
|
| Keybinding panel inside the Reka Settings dialog |
![Keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/keybinding-panel.png)
|
| Nested PrimeVue **Modify keybinding** dialog stacked on top —
`document.activeElement` is the `<input autofocus>`, proving the
focus-trap fix | ![Modify
keybinding](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/c454af1888d5d8d88092475b498cff7d2adac1a1/temp/summaries/nested-modify-keybinding.png)
|


## Public API impact

None. `useSettingsDialog().show()` keeps the same signature. Reka
primitives gain optional `maximized` prop and `overlayClass` field —
additive, non-breaking.

## Out of scope (later phases)

- Manager dialog — Phase 4 (FE-576) — will consume the new `maximizable`
affordance
- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Review focus

1. **Sizing strategy** — `contentClass` overrides Reka's default content
sizing (matching the existing `BaseModalLayout size="sm"` of 960 ×
80vh). Worth a designer pass per FE-575's acceptance criteria.
2. **`overlayClass: 'p-8'` workspace mode** — Reka's `DialogContent` is
positioned with viewport coordinates, so overlay padding does not
constrain it the way the old PrimeVue `mask.p-8` did. Cosmetic gutter
only. If designer flags missing breathing room, follow-up by shrinking
`contentClass` in workspace mode.
3. **`headless: true` semantics for Reka** — now skips the inner padding
wrapper. Existing migrated dialogs (Phases 1–2) all set a header, so no
visible impact. The Reka-headless path is new with this PR.
4. **Maximize wiring** — `toggleMaximize` mutates
`item.dialogComponentProps.maximized` directly (Pinia deep-reactive
proxy). The store's `onMaximize` / `onUnmaximize` callbacks are still
wired for the PrimeVue path; not double-fired.

## Test plan

- [x] Unit: 102/102 across touched + adjacent areas
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Open Settings via gear icon / keyboard shortcut → renders through
Reka, search works, panel navigation works, ESC closes
- Open Settings → trigger a reset confirmation (stacked confirm) →
confirm renders above Settings, ESC closes only the confirm
- Cloud workspace mode: Settings opens with workspace panel;
`overlayClass` applied
- Cloud non-workspace mode: Settings opens without workspace panel; no
`overlayClass`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12182-feat-dialog-migrate-Settings-dialog-to-Reka-UI-Phase-3-35e6d73d36508144bb4af88f83c5ab20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-27 22:08:30 +00:00
Luke Mino-Altherr
db6b7a315c chore: remediate 51 Dependabot vulnerabilities (#12345)
## Summary

Remediate 51 of 63 open Dependabot security alerts by bumping direct
dependencies, bumping parent dependencies, and adding targeted pnpm
overrides for transitive dependencies.

## Changes

- **What**: Two batches of dependency security fixes
- **Batch 1**: Bump catalog minimums for axios, dompurify, happy-dom,
vite, uuid. Fix axios header type narrowing in api.ts.
- **Batch 2**: Bump parent deps (@iconify/tailwind4, vue, knip) to pull
fixed transitive deps. Add tilde-pinned pnpm overrides for protobufjs,
flatted, defu where no parent fix is available. Unexport 6 unused types
flagged by knip upgrade.
- **Dependencies**: vue 3.5.13->3.5.34 required two type fixes
(LazyImage ClassValue, dialogStore deep instantiation)

## Review Focus

- pnpm overrides in package.json: protobufjs ~7.6.0, flatted ~3.4.2,
defu ~6.1.7
- Vue 3.5.34 type narrowing fixes in LazyImage.vue and dialogStore.ts

## Remaining (12 alerts, separate PRs)

- minimatch (4H) - 4 major version lines, needs per-consumer analysis
- picomatch (2M) - two major version lines
- brace-expansion (2M) - multiple major version lines
- astro (2: 1L+1M) - major version bump 5->6
- postcss 8.5.8 (1M) - dev-only, from @vue/compiler-sfc@3.5.28 via
storybook/devtools
- yaml 1.10.2 (1M) - from cosmiconfig->nx, no upstream fix in yaml v1
- lodash/lodash-es (4: 2H+2M) - dev-only, upstream still uses 4.17.x
- @babel/plugin-transform-modules-systemjs (1H) - dev-only via nx
- fast-uri (2H) - dev-only via ajv->nx/stylelint

Fixes #FE-762

---------

Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-27 14:07:34 -07:00
AustinMroz
b89940134f Better preview grid tiling (#12463)
The previous image preview tiling code was less than ideal. It had fixed
breakpoints based on the number of images. Outputs with many images
would become comically long.

This PR instead tiles images to fill the available space.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e793ce65-8efc-44ca-b049-98f066a65b7d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/ca891ce2-335f-42ce-aeec-a99579f669c8"
/>|
2026-05-27 20:26:44 +00:00
Christian Byrne
7ac1cbbd53 test: add E2E coverage for NE, SW, NW corner node resizing (#11408)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Adds parameterized Playwright E2E tests covering all non-SE resize
corners (NE, SW, NW), closing the coverage gap in the `useNodeResize.ts`
switch statement
- Adds `resizeFromCorner()` and `getResizeHandle()` to `VueNodeFixture`
for reuse across tests
- Test cases are derived from the production `RESIZE_HANDLES` config so
they stay in sync with the actual handle definitions

## Test Groups (8 new tests)

| Group | Tests | Coverage |
|-------|-------|----------|
| Corner resize directions | NE, SW, NW — size increases and correct
edges shift | Lines 110-124, 184 |
| Opposite edge anchoring | NE, SW, NW — opposite corner stays fixed |
Position compensation end-to-end |
| Minimum size enforcement | SW width clamp (≥ MIN_NODE_WIDTH), NE
height clamp | Lines 162-176 |

## Design Decisions

**Locator-based handle discovery**: `resizeFromCorner()` finds handles
via `getByRole('button', { name: ariaLabel })` instead of coordinate
offsets. The resize handles have `opacity-0 pointer-events-auto`,
meaning they're always interactive even when visually transparent —
Playwright considers elements with `opacity: 0` as visible (it only
gates on `visibility: hidden` / `display: none` / zero-size bounding
box). If this approach turns out to be flaky in CI due to handle
discoverability, we can fall back to coordinate-based targeting
(computing offsets from the node's bounding box corners), which is what
the original SE-corner test uses.

**Parameterization from production config**: Tests import
`RESIZE_HANDLES` from `resizeHandleConfig.ts` and derive test case data
(drag direction, which axes move) from the corner name. An upfront guard
throws if any expected corner is missing from the config, preventing
silent coverage loss.

**Aria-label coupling**: `RESIZE_HANDLE_LABELS` in `VueNodeFixture`
hardcodes the English aria-label strings. This is intentional — tests
run in English locale, and aria-labels are the accessibility interface
contract. If a more stable hook is needed (e.g., `data-testid` per
handle), that can be added to `LGraphNode.vue` in a follow-up.

**Frame settlement**: `resizeFromCorner()` calls `nextFrame()` after the
mouse-up to ensure layout settles before assertions run, per
`FLAKE_PREVENTION_RULES.md`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11408-test-add-E2E-coverage-for-NE-SW-NW-corner-node-resizing-3476d73d3650818d8a5ce5d6d535b38c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-27 17:26:59 +00:00
jaeone94
7caba4408d fix: open model library for desktop model downloads (#12478)
## Summary

Open the Model Library sidebar when a missing-model download starts on
desktop so users can immediately see Electron download progress. This is
a temporary UX patch until the dedicated desktop missing-model download
progress bar lands.

## Changes

- **What**: Activates the `model-library` sidebar tab before starting
desktop missing-model downloads.
- **Dependencies**: None.

## Review Focus

Confirm the minimal behavior is scoped to the desktop download path and
covers both individual downloads and Download all through the shared
`downloadModel()` helper. This should remain small and easy to remove
once the progress bar flow is available.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/d5b01db7-46b5-4a52-bb11-45e75a422474



## Test Plan

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
2026-05-27 15:25:26 +00:00
jaeone94
fa4ffe3254 Fix node tooltip metadata i18n parsing (#12469)
## Summary

Fix node slot and widget tooltips so localized nodeDef tooltip text is
displayed without treating JSON examples from node metadata as vue-i18n
placeholders.

## Changes

- **What**: Added a tooltip-specific helper that keeps the existing
`te(key)` lookup behavior. When a translation key exists, it reads the
current locale message with `tm(key)` instead of compiling it with
`t(key)`.
- **What**: This does **not** disable tooltip localization. Existing
nodeDef tooltip translations are still used; the object_info metadata
tooltip remains the fallback when no translation key exists.
- **What**: Used the helper for node input, output, and widget tooltips
while keeping node descriptions on the existing translation path.
- **What**: Added regression coverage for the SAM3 Detect
`positive_coords` input tooltip, input-based widget tooltip, and output
slot tooltip paths.
- **Dependencies**: None.

## Review Focus

Please verify that slot and widget tooltips still prefer bundled nodeDef
translations while avoiding vue-i18n placeholder compilation for
metadata strings like `[{"x": int, "y": int}]`.

Linear: FE-813

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm exec vitest run
src/renderer/extensions/vueNodes/composables/useNodeTooltips.test.ts`
- `pnpm test:unit`
- E2E was not added because this is covered more directly by unit tests
for the i18n tooltip string path; a browser flow would add little signal
for this regression.
2026-05-27 11:51:53 +00: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
209 changed files with 14879 additions and 9383 deletions

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

@@ -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

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
export type NavDropdownItem = {
type NavDropdownItem = {
label: string
href: string
badge?: string

View File

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

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
interface DroppedNode {
name: string
reason: string
}

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

@@ -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

@@ -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

@@ -128,7 +128,8 @@ export const TestIds = {
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image',
slotConnectionDot: 'slot-connection-dot'
slotConnectionDot: 'slot-connection-dot',
imageGrid: 'image-grid'
},
selectionToolbox: {
root: 'selection-toolbox',

View File

@@ -511,8 +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()
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

@@ -1,8 +1,15 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -15,7 +22,9 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly imageGrid: Locator
public readonly content: Locator
public readonly resize: { bottomRight: Locator }
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -28,7 +37,10 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
this.content = locator.locator('.lg-node-content')
const bottomRight = locator.getByRole('button', { name: 'bottom-right' })
this.resize = { bottomRight }
}
async getTitle(): Promise<string> {
@@ -77,4 +89,100 @@ export class VueNodeFixture {
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out because geometry-sensitive tests
* cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, {
steps: 5
})
await page.mouse.up()
}
}

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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

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

@@ -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

@@ -1,11 +1,15 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
@@ -111,12 +115,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')
@@ -138,3 +140,44 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
}
)
})
async function countColumns(locator: Locator) {
return await locator.locator('img').evaluateAll((images) => {
const yOffsets = images.map((image) => image.getBoundingClientRect().y)
return yOffsets.filter((yOffset) => yOffset === yOffsets[0]).length
})
}
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
wstest(
'Image previews tile to fit node',
async ({ comfyMouse, comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Inject multiple previews', async () => {
const file = { filename: 'example.png', type: 'input' }
const images = new Array(100).fill(file)
execution.executed('', '1', { images })
await expect(node.imageGrid.locator('img')).toHaveCount(100)
})
const { bottomRight } = node.resize
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
})

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

@@ -1,56 +1,165 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
// Select the node first (this was causing the bug)
await node.header.click()
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.expectAnchoredAt(initialBox)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.resizeFromCorner('SE', 50, 30)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)

View File

@@ -9,25 +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], name: 'added_widget_1' })
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], name: 'added_widget_2' }
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],
name: 'added_widget_3'
})
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
})
@@ -55,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

@@ -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

@@ -43,7 +43,6 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.2",
"version": "1.46.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dompurify": "catalog:",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"uuid": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",

View File

@@ -1892,3 +1892,17 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

1685
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ catalog:
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@iconify/tailwind4': ^1.2.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
@@ -66,10 +66,10 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
dompurify: ^3.4.5
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
@@ -87,12 +87,12 @@ catalog:
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.0.11
happy-dom: ^20.8.9
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.3.1
knip: ^6.14.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,13 +108,13 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: ^2.5.0
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
@@ -123,13 +123,14 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.0
vite: ^8.0.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.13
vue: ^3.5.34
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -160,3 +161,13 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
protobufjs: ~7.6.0
flatted: ~3.4.2
defu: ~6.1.7
# Security overrides (see pnpm.overrides in package.json for the actual pins):
# protobufjs ~7.6.0 — CVE-2026-41242 (CVSS 9.8): arbitrary code execution.
# Transitive via firebase, posthog-js. Remove after firebase upgrades protobufjs.
# flatted ~3.4.2 — GHSA-x7hr-w5r2-h6qg: prototype pollution.
# Transitive via eslint flat-cache@4.0.1. Dev-only. Remove after eslint upgrades flat-cache.
# defu ~6.1.7 — GHSA-47f6-5gq3-vx9c: prototype pollution.
# Transitive via reka-ui, c12, unplugin-typegpu. Remove after reka-ui upgrades defu.

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

@@ -42,7 +42,8 @@ import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@comfyorg/tailwind-utils'
type ClassValue = string | Record<string, boolean> | ClassValue[]
const {
src,

View File

@@ -8,6 +8,10 @@ import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -190,3 +194,88 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
return {
detail: { originalEvent: { target } },
preventDefault: () => {
prevented = true
},
get defaultPrevented() {
return prevented
}
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
defaultPrevented: boolean
}
}
it.for([
'p-select-overlay',
'p-colorpicker-panel',
'p-popover',
'p-autocomplete-overlay',
'p-overlay-mask',
'p-dialog'
])('prevents dismiss when target is inside %s', (className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
})
it('allows dismiss when target is outside any PrimeVue overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: false }, event)
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
}
)
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')
document.body.appendChild(portal)
const event = makeEvent(portal)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
portal.remove()
})
})

View File

@@ -8,9 +8,14 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogOverlay
v-reka-z-index
:class="item.dialogComponentProps.overlayClass"
/>
<DialogContent
v-reka-z-index
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -19,34 +24,51 @@
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<template v-if="item.dialogComponentProps.headless">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -55,7 +77,6 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -86,29 +107,25 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -119,20 +136,8 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
}
</script>
@@ -163,19 +168,6 @@ function getDialogPt(item: {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -244,7 +244,7 @@
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"

View File

@@ -0,0 +1,49 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
function isInsideOverlay(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
)
}
export function onRekaPointerDownOutside(
options: { dismissableMask?: boolean },
event: OutsideEvent
) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
return
}
if (options.dismissableMask === false) {
event.preventDefault()
}
}
// Focus / interact-outside fires when focus moves to a sibling portal (a
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}
}

View File

@@ -0,0 +1,17 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
// can lose to an already-open PrimeVue dialog. Registering Reka's content
// element with the same ZIndex counter (key 'modal', base 1700) makes both
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)
}
}

View File

@@ -0,0 +1,222 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n, te } from '@/i18n'
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import NodeTooltip from './NodeTooltip.vue'
type HitTest = (
node: MockNode,
x: number,
y: number,
offset: [number, number]
) => number
interface MockWidget {
name: string
tooltip?: string
}
interface MockNode {
type: string
flags: {
collapsed?: boolean
ghost?: boolean
}
pos: [number, number]
inputs: Array<{ name: string }>
constructor: {
title_mode?: 0 | 1 | 2 | 3
}
}
interface MockCanvas {
mouse: [number, number]
graph_mouse: [number, number]
node_over: MockNode | null
getWidgetAtCursor: () => MockWidget | null
}
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsDOMWidget = vi.hoisted(() =>
vi.fn<(widget: MockWidget) => boolean>()
)
const mockCanvas = vi.hoisted(
(): MockCanvas => ({
mouse: [100, 80],
graph_mouse: [10, 10],
node_over: null,
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
})
)
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LiteGraphModule>()
return {
...actual,
isOverNodeInput: mockIsOverNodeInput,
isOverNodeOutput: mockIsOverNodeOutput
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: mockCanvas
}
}))
vi.mock('@/scripts/domWidget', () => ({
isDOMWidget: mockIsDOMWidget
}))
const jsonTooltip =
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
const positiveCoordsTooltipKey =
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const sam3DetectNodeDef: ComfyNodeDef = {
name: 'SAM3_Detect',
display_name: 'SAM3 Detect',
category: 'detection/',
python_module: 'comfy_extras.nodes_sam3',
description: '',
input: {
required: {},
optional: {
positive_coords: [
'STRING',
{
tooltip: jsonTooltip,
forceInput: true
}
]
}
},
output: ['MASK'],
output_name: ['masks'],
output_tooltips: [jsonTooltip],
output_node: false,
deprecated: false,
experimental: false
}
function createSam3Node(): MockNode {
return {
type: 'SAM3_Detect',
flags: {},
pos: [0, 0],
inputs: [{ name: 'positive_coords' }],
constructor: {}
}
}
function mergeOutputTooltipMessage(tooltip: string | null) {
i18n.global.mergeLocaleMessage('en', {
nodeDefs: {
SAM3_Detect: {
outputs: {
0: {
tooltip
}
}
}
}
})
}
async function renderAndHoverCanvas() {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(NodeTooltip)
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
await user.hover(canvas)
await vi.runOnlyPendingTimersAsync()
await nextTick()
}
describe('NodeTooltip', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(useSettingStore(), 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'LiteGraph.Node.TooltipDelay':
return 0 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
mockCanvas.mouse = [100, 80]
mockCanvas.graph_mouse = [10, 10]
mockCanvas.node_over = createSam3Node()
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
mergeOutputTooltipMessage(jsonTooltip)
})
afterEach(() => {
mergeOutputTooltipMessage(null)
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(outputTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
name: 'positive_coords'
})
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
})

View File

@@ -13,7 +13,7 @@
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { stRaw } from '@/i18n'
import {
LiteGraph,
isOverNodeInput,
@@ -84,7 +84,7 @@ function onIdle() {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef?.inputs[inputName]?.tooltip ?? ''
)
@@ -98,7 +98,7 @@ function onIdle() {
[0, 0]
)
if (outputSlot !== -1) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
)
@@ -108,7 +108,7 @@ function onIdle() {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef?.inputs[widget.name]?.tooltip ?? ''
)

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

@@ -10,11 +10,13 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -26,7 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,14 +10,19 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md'
size: 'md',
maximized: false
}
})
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentSize = NonNullable<DialogContentVariants['size']>

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,
@@ -27,6 +29,8 @@ 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,
@@ -56,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
@@ -228,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
}
}
@@ -257,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
@@ -307,8 +306,7 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
@@ -321,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,
@@ -386,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

@@ -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

@@ -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
})
}
}

View File

@@ -0,0 +1,895 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
flushProxyWidgetMigration,
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LGraph.proxyWidgetMigrationFlush = undefined
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return hostNode
}
function addInnerNode(
host: SubgraphNode,
type: string,
build: (node: LGraphNode) => void = () => {}
): LGraphNode {
const node = new LGraphNode(type)
build(node)
host.subgraph.add(node)
return node
}
function addPromotedHostInput(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
}
function addPrimitiveWithTargets(
host: SubgraphNode,
args: {
primitiveType?: string
primitiveValue?: number
targetCount: number
outputType?: string
targetSlotType?: string
}
): { primitive: LGraphNode; targets: LGraphNode[] } {
const outputType = args.outputType ?? 'INT'
const targetSlotType = args.targetSlotType ?? outputType
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', outputType)
primitive.addWidget('number', 'value', args.primitiveValue ?? 42, () => {})
host.subgraph.add(primitive)
const targets: LGraphNode[] = []
for (let i = 0; i < args.targetCount; i++) {
const target = new LGraphNode(`Target${i}`)
const slot = target.addInput('value', targetSlotType)
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
host.subgraph.add(target)
primitive.connect(0, target, 0)
targets.push(target)
}
return { primitive, targets }
}
describe('flushProxyWidgetMigration', () => {
describe('no-op cases', () => {
it('returns an empty result when no proxyWidgets are present', () => {
const host = buildHost()
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('tolerates a malformed proxyWidgets payload and returns empty', () => {
const host = buildHost()
host.properties.proxyWidgets = '{not json}'
expect(() => flushProxyWidgetMigration({ hostNode: host })).not.toThrow()
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 0, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'missingSubgraphInput'
})
])
})
})
describe('primitive fan-out repair', () => {
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 3
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
for (const target of targets) {
const slot = target.inputs[0]
expect(slot.link).not.toBeNull()
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('coalesces duplicate cohort entries pointing at the same primitive', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 2
})
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
for (const target of targets) {
const slot = target.inputs[0]
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('host value wins over primitive widget value', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 2,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', '*')
host.subgraph.add(primitive)
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'unlinkedSourceWidget'
})
])
})
it('quarantines all cohort entries when a target slot type is incompatible', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
targets[0].inputs[0].type = 'STRING'
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
const danglingLinkId = 999_999
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
primitive.outputs[0].links = [
...(primitive.outputs[0].links ?? []),
danglingLinkId
]
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps independent values across two hosts of the same subgraph', () => {
const subgraph = createTestSubgraph()
const hostA = createTestSubgraphNode(subgraph)
const hostB = createTestSubgraphNode(subgraph)
hostA.graph!.add(hostA)
hostB.graph!.add(hostB)
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
primitive.addWidget('number', 'value', 0, () => {})
subgraph.add(primitive)
const target = new LGraphNode('Target')
const slot = target.addInput('value', 'INT')
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
subgraph.add(target)
primitive.connect(0, target, 0)
hostA.properties.proxyWidgets = [[String(primitive.id), 'value']]
hostB.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: hostA,
hostWidgetValues: [11]
})
flushProxyWidgetMigration({
hostNode: hostB,
hostWidgetValues: [22]
})
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
})
})
describe('preview exposure migration', () => {
it('adds an exposure for a $$-prefixed preview source', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
expect(exposures[0].sourceNodeId).toBe(String(inner.id))
})
it('classifies type:preview serialize:false widgets as preview exposure', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const widget = n.addWidget('text', 'videopreview', '', () => {})
widget.type = 'preview'
widget.serialize = false
})
host.properties.proxyWidgets = [[String(inner.id), 'videopreview']]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: 'videopreview'
})
])
})
it('produces a unique name on collision via nextUniqueName', () => {
const host = buildHost()
const innerA = addInnerNode(host, 'InnerA', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const innerB = addInnerNode(host, 'InnerB', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerA.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(innerB.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = store.getExposures(host.rootGraph.id, locator)
expect(exposures).toHaveLength(2)
const newExposure = exposures.find(
(e) => e.sourceNodeId === String(innerB.id)
)
expect(newExposure?.name).toBe('$$canvas-image-preview_1')
})
it('reuses an existing exposure for the same source preview', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
})
describe('quarantine accumulation', () => {
it('quarantines entries whose source node has disappeared', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
{
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1
}
])
})
it('quarantines entries whose source widget is missing on the source node', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner')
host.properties.proxyWidgets = [[String(inner.id), 'nonexistent']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'nonexistent'],
reason: 'missingSourceWidget'
})
])
})
it('preserves the host value on the quarantine row when one was supplied', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [42]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
})
it('round-trips appended entries via the public read helper', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const first = readHostQuarantine(host)
expect(first).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed', 'inner-leaf']]
flushProxyWidgetMigration({ hostNode: host })
const after = readHostQuarantine(host)
expect(after).toHaveLength(2)
expect(after.map((e) => e.originalEntry)).toEqual([
['9999', 'seed'],
['9999', 'seed', 'inner-leaf']
])
})
it('deduplicates entries with identical originalEntry tuples on re-flush', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const firstQuarantine = readHostQuarantine(host)
expect(firstQuarantine).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
})
describe('idempotency', () => {
it('clears properties.proxyWidgets after a successful flush', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('re-running flush over a fully migrated host produces no further mutations', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
expect(exposuresAfterFirst).toHaveLength(1)
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual(exposuresAfterFirst)
})
})
describe('mixed cohort', () => {
it('migrates a mixed value+preview cohort in one flush, preserving entry order', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const subgraphInputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [
[String(inner.id), 'seed'],
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
expect(host.subgraph.inputs.find((i) => i.name === 'seed')).toBeDefined()
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
})
it('preserves sparse holes when supplied widgets_values is missing an index', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slotA = n.addInput('a', 'INT')
slotA.widget = { name: 'a' }
n.addWidget('number', 'a', 0, () => {})
const slotB = n.addInput('b', 'INT')
slotB.widget = { name: 'b' }
n.addWidget('number', 'b', 0, () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), 'a'],
[String(inner.id), 'b']
]
const sparse: unknown[] = []
sparse[1] = 'second-value'
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(host.subgraph.inputs.find((i) => i.name === 'a')).toBeDefined()
expect(host.subgraph.inputs.find((i) => i.name === 'b')).toBeDefined()
})
})
describe('integration with LGraph.configure', () => {
it('runs through LGraph.configure when the migration hook is wired', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
const serialized = host.rootGraph.serialize()
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
const reloadedGraph = new LGraph()
const subgraph = host.subgraph
const instanceData = host.serialize()
LiteGraph.registerNodeType(
subgraph.id,
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(reloadedGraph, subgraph, instanceData)
}
}
)
try {
reloadedGraph.configure(serialized)
} finally {
LiteGraph.unregisterNodeType(subgraph.id)
}
const reloadedHost = reloadedGraph.getNodeById(host.id)
expect(reloadedHost?.properties.proxyWidgets).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
})
})
describe('normalizeLegacyProxyWidgetEntry', () => {
function createHostWithInnerWidget(widgetName: string) {
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
subgraph.add(innerNode)
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return { innerNode, hostNode }
}
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 innerSubgraph = createTestSubgraph()
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)
const hostNode = createTestSubgraphNode(innerSubgraph)
hostNode.graph!.add(hostNode)
const prefixedName = `${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(samplerNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})
})
})

View File

@@ -0,0 +1,790 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
getPromotableWidgets,
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
ProxyWidgetErrorQuarantineEntry,
ProxyWidgetQuarantineReason
} from '@/core/schemas/proxyWidgetQuarantineSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
}
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
interface StrippedPrefix {
sourceWidgetName: string
deepestPrefixId?: string
}
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
let remaining = sourceWidgetName
let deepestPrefixId: string | undefined
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
deepestPrefixId = match[1]
remaining = match[2]
}
}
function canResolveLegacyProxy(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string
): boolean {
return (
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
'resolved'
)
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): LegacyProxyEntrySource {
if (canResolveLegacyProxy(hostNode, sourceNodeId, sourceWidgetName)) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const stripped = stripLegacyPrefixes(sourceWidgetName)
const patchDisambiguatingSourceNodeId =
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}
function resolveSourceWidget(
sourceNode: LGraphNode,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
}
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
)
}
interface FlushArgs {
hostNode: SubgraphNode
hostWidgetValues?: readonly unknown[]
}
interface PrimitiveBypassTargetRef {
targetNodeId: NodeId
targetSlot: number
}
type Plan =
| { kind: 'alreadyLinked'; subgraphInputName: string }
| { kind: 'createSubgraphInput'; sourceWidgetName: string }
| {
kind: 'primitiveBypass'
primitiveNodeId: NodeId
sourceWidgetName: string
targets: readonly PrimitiveBypassTargetRef[]
}
| { kind: 'previewExposure'; sourcePreviewName: string }
| { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason }
interface PendingEntry {
normalized: LegacyProxyEntrySource
hostValue: TWidgetValue | undefined
isHole: boolean
plan: Plan
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
const QUARANTINE_VERSION = 1
const PROXY_BYPASS_MARKER_PROPERTY = 'proxyBypassedToSubgraphInput'
export function flushProxyWidgetMigration(args: FlushArgs): void {
const { hostNode, hostWidgetValues } = args
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) return
const cohort: LegacyProxyEntrySource[] = tuples.map(
([sourceNodeId, sourceWidgetName, disambiguator]) =>
normalizeLegacyProxyWidgetEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguator
)
)
const pending: PendingEntry[] = cohort.map((normalized, index) => {
const { value, isHole } = pickHostValue(hostWidgetValues, index)
return {
normalized,
hostValue: value,
isHole,
plan: classify(hostNode, normalized, cohort)
}
})
const previewStore = usePreviewExposureStore()
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
const primitiveCohorts = new Map<NodeId, PendingEntry[]>()
for (const entry of pending) {
switch (entry.plan.kind) {
case 'primitiveBypass': {
const c = primitiveCohorts.get(entry.plan.primitiveNodeId) ?? []
c.push(entry)
primitiveCohorts.set(entry.plan.primitiveNodeId, c)
break
}
case 'alreadyLinked': {
const r = repairAlreadyLinked(
hostNode,
entry,
entry.plan.subgraphInputName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'createSubgraphInput': {
const r = repairCreateSubgraphInput(
hostNode,
entry,
entry.plan.sourceWidgetName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'previewExposure': {
const r = migratePreview(hostNode, entry, previewStore, entry.plan)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'quarantine':
quarantineToAppend.push(quarantineFor(entry, entry.plan.reason))
break
}
}
for (const c of primitiveCohorts.values()) {
const r = repairPrimitive(hostNode, c)
if (!r.ok)
for (const e of c) quarantineToAppend.push(quarantineFor(e, r.reason))
}
if (quarantineToAppend.length > 0) {
appendQuarantine(hostNode, quarantineToAppend)
}
delete hostNode.properties.proxyWidgets
}
function pickHostValue(
hostWidgetValues: readonly unknown[] | undefined,
index: number
): { value: TWidgetValue | undefined; isHole: boolean } {
if (
hostWidgetValues === undefined ||
index < 0 ||
index >= hostWidgetValues.length ||
!Object.hasOwn(hostWidgetValues, index)
) {
return { value: undefined, isHole: true }
}
const raw = hostWidgetValues[index]
if (!isWidgetValue(raw)) return { value: undefined, isHole: true }
return { value: raw, isHole: false }
}
function collectTargetsStrict(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] | undefined {
const subgraph = hostNode.subgraph
const output = primitiveNode.outputs?.[0]
const linkIds = output?.links ?? []
const targets: PrimitiveBypassTargetRef[] = []
for (const linkId of linkIds) {
const link = subgraph.links.get(linkId)
if (!link) return undefined
targets.push({
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
return targets
}
function collectTargetsSkippingDangling(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] {
const subgraph = hostNode.subgraph
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
: []
})
}
function cohortDuplicatesPrimitive(
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
): boolean {
return (
cohort.filter((entry) => entry.sourceNodeId === primitiveNodeId).length >= 2
)
}
function classify(
hostNode: SubgraphNode,
normalized: LegacyProxyEntrySource,
cohort: readonly LegacyProxyEntrySource[]
): Plan {
const linkedInput = findHostInputForPromotion(
hostNode,
normalized.sourceNodeId,
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
if (!sourceNode) {
return { kind: 'quarantine', reason: 'missingSourceNode' }
}
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
const bypassedTo = sourceNode.properties?.[PROXY_BYPASS_MARKER_PROPERTY]
if (typeof bypassedTo === 'string') {
const existingInput = hostNode.inputs.find(
(input) => input.name === bypassedTo
)
if (existingInput) {
return { kind: 'alreadyLinked', subgraphInputName: existingInput.name }
}
}
const targets = collectTargetsSkippingDangling(hostNode, sourceNode)
const cohortDuplicated = cohortDuplicatesPrimitive(
cohort,
normalized.sourceNodeId
)
if (targets.length >= 1 || cohortDuplicated) {
return {
kind: 'primitiveBypass',
primitiveNodeId: sourceNode.id,
sourceWidgetName: normalized.sourceWidgetName,
targets
}
}
return { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
normalized.sourceWidgetName,
normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { kind: 'quarantine', reason: 'missingSourceWidget' }
}
if (
normalized.sourceWidgetName.startsWith('$$') ||
isPreviewPseudoWidget(sourceWidget)
) {
return {
kind: 'previewExposure',
sourcePreviewName: normalized.sourceWidgetName
}
}
return {
kind: 'createSubgraphInput',
sourceWidgetName: normalized.sourceWidgetName
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
}
function addUniqueSubgraphInput(
subgraph: Subgraph,
baseName: string,
type: string
): SubgraphInput {
const existingNames = subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(baseName, existingNames)
return subgraph.addInput(uniqueName, type)
}
type Outcome<TOk, TReason = ProxyWidgetQuarantineReason> =
| ({ ok: true } & TOk)
| { ok: false; reason: TReason }
type RepairValueResult = Outcome<{ subgraphInputName: string }>
function repairAlreadyLinked(
hostNode: SubgraphNode,
entry: PendingEntry,
subgraphInputName: string
): RepairValueResult {
// Resolve by name directly: source-id matching would miss for primitive
// bypasses, where the view's `sourceNodeId` is the consumer, not the
// primitive.
const matches = hostNode.inputs.filter(
(input) => input.name === subgraphInputName
)
if (matches.length === 0) {
return { ok: false, reason: 'missingSubgraphInput' }
}
if (matches.length > 1) {
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
function repairCreateSubgraphInput(
hostNode: SubgraphNode,
entry: PendingEntry,
sourceWidgetName: string
): RepairValueResult {
const subgraph = hostNode.subgraph
const sourceNode: LGraphNode | null = subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
sourceWidgetName,
entry.normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { ok: false, reason: 'missingSourceWidget' }
}
const slot: INodeInputSlot | undefined =
sourceNode.getSlotFromWidget(sourceWidget)
if (!slot) {
console.warn(
'[proxyWidgetMigration] source widget has no backing input slot; quarantining',
{
sourceNodeId: entry.normalized.sourceNodeId,
sourceWidgetName
}
)
return { ok: false, reason: 'missingSubgraphInput' }
}
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
const newSubgraphInput = addUniqueSubgraphInput(
subgraph,
sourceWidgetName,
slotType
)
if (slot.label !== undefined) newSubgraphInput.label = slot.label
const link = newSubgraphInput.connect(slot, sourceNode)
if (!link) {
subgraph.removeInput(newSubgraphInput)
return { ok: false, reason: 'missingSubgraphInput' }
}
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
type RepairPrimitiveResult = Outcome<
{ subgraphInputName: string; reconnectCount: number },
'primitiveBypassFailed'
>
const PRIMITIVE_FAILED: RepairPrimitiveResult = {
ok: false,
reason: 'primitiveBypassFailed'
}
interface SnapshotLink extends PrimitiveBypassTargetRef {
primitiveSlot: number
}
interface CohortValidationOk {
ok: true
primitiveNodeId: NodeId
sourceWidgetName: string
uniqueEntries: readonly PendingEntry[]
}
function failPrimitive(message: string, ctx?: unknown): RepairPrimitiveResult {
console.warn(`[proxyWidgetMigration] ${message}`, ctx)
return PRIMITIVE_FAILED
}
function userRenamedTitle(primitiveNode: LGraphNode): string | undefined {
const title = primitiveNode.title
return title && title !== PRIMITIVE_NODE_TYPE ? title : undefined
}
function validateCohort(
cohort: readonly PendingEntry[]
): CohortValidationOk | { ok: false } {
const first = cohort[0]
if (!first || first.plan.kind !== 'primitiveBypass') return { ok: false }
const { primitiveNodeId, sourceWidgetName } = first.plan
for (const entry of cohort) {
if (
entry.plan.kind !== 'primitiveBypass' ||
entry.plan.primitiveNodeId !== primitiveNodeId ||
entry.plan.sourceWidgetName !== sourceWidgetName
) {
return { ok: false }
}
}
const uniqueEntries: PendingEntry[] = []
for (const entry of cohort) {
if (!uniqueEntries.some((k) => isEqual(k.normalized, entry.normalized))) {
uniqueEntries.push(entry)
}
}
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
}
function rollback(
hostNode: SubgraphNode,
primitiveNode: LGraphNode,
newSubgraphInput: SubgraphInput | undefined,
snapshot: readonly SnapshotLink[]
): void {
if (newSubgraphInput) {
try {
hostNode.subgraph.removeInput(newSubgraphInput)
} catch (e) {
console.warn('[proxyWidgetMigration] rollback removeInput failed', e)
}
}
for (const link of snapshot) {
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
if (!targetNode) continue
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
}
}
function repairPrimitive(
hostNode: SubgraphNode,
cohort: readonly PendingEntry[]
): RepairPrimitiveResult {
const validated = validateCohort(cohort)
if (!validated.ok)
return failPrimitive('cohort validation failed', { cohort })
const subgraph = hostNode.subgraph
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
if (!primitiveNode) return failPrimitive('primitive node missing', validated)
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
return failPrimitive('node is not a PrimitiveNode', primitiveNode.type)
}
const targets = collectTargetsStrict(hostNode, primitiveNode)
if (!targets?.length)
return failPrimitive('no targets to reconnect', validated)
const primitiveOutput = primitiveNode.outputs?.[0]
if (!primitiveOutput) return failPrimitive('primitive has no output')
const primitiveOutputType = String(primitiveOutput.type ?? '*')
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode) return failPrimitive('target node missing', target)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot) return failPrimitive('target slot missing', target)
const targetType = String(targetSlot.type ?? '*')
if (
targetType !== primitiveOutputType &&
targetType !== '*' &&
primitiveOutputType !== '*'
) {
return failPrimitive('target slot type incompatible', {
target,
targetType,
primitiveOutputType
})
}
}
const baseName = userRenamedTitle(primitiveNode) ?? validated.sourceWidgetName
const snapshot: SnapshotLink[] = (primitiveOutput.links ?? [])
.map((id) => subgraph.links.get(id))
.filter((l): l is NonNullable<typeof l> => l !== undefined)
.map((l) => ({
primitiveSlot: l.origin_slot,
targetNodeId: l.target_id,
targetSlot: l.target_slot
}))
let newSubgraphInput: SubgraphInput | undefined
try {
newSubgraphInput = addUniqueSubgraphInput(
subgraph,
baseName,
primitiveOutputType
)
for (const snap of snapshot) {
const targetNode = subgraph.getNodeById(snap.targetNodeId)
if (!targetNode)
throw new Error(
`target node ${snap.targetNodeId} disappeared mid-mutation`
)
targetNode.disconnectInput(snap.targetSlot, false)
}
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode)
throw new Error(`target node ${target.targetNodeId} disappeared`)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot)
throw new Error(`target slot ${target.targetSlot} disappeared`)
const link = newSubgraphInput.connect(targetSlot, targetNode)
if (!link) {
throw new Error('SubgraphInput.connect returned no link')
}
}
} catch (e) {
rollback(hostNode, primitiveNode, newSubgraphInput, snapshot)
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false
})
}
}
}
primitiveNode.properties ??= {}
primitiveNode.properties[PROXY_BYPASS_MARKER_PROPERTY] = newSubgraphInput.name
return {
ok: true,
subgraphInputName: newSubgraphInput.name,
reconnectCount: targets.length
}
}
type MigratePreviewResult = Outcome<
{ previewName: string },
'missingSourceNode' | 'missingSourceWidget'
>
function migratePreview(
hostNode: SubgraphNode,
entry: PendingEntry,
store: ReturnType<typeof usePreviewExposureStore>,
plan: { kind: 'previewExposure'; sourcePreviewName: string }
): MigratePreviewResult {
const sourceNode = hostNode.subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$')
if (!isCanonicalPseudo) {
const widget = sourceNode.widgets?.find(
(w) => w.name === plan.sourcePreviewName
)
if (!widget) {
return { ok: false, reason: 'missingSourceWidget' }
}
}
const hostNodeLocator = String(hostNode.id)
const existing = store
.getExposures(hostNode.rootGraph.id, hostNodeLocator)
.find(
(exposure) =>
exposure.sourceNodeId === entry.normalized.sourceNodeId &&
exposure.sourcePreviewName === plan.sourcePreviewName
)
if (existing) return { ok: true, previewName: existing.name }
const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, {
sourceNodeId: entry.normalized.sourceNodeId,
sourcePreviewName: plan.sourcePreviewName
})
return { ok: true, previewName: added.name }
}
function quarantineFor(
entry: PendingEntry,
reason: ProxyWidgetQuarantineReason
): ProxyWidgetErrorQuarantineEntry {
const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } =
entry.normalized
const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId
? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId]
: [sourceNodeId, sourceWidgetName]
return makeQuarantineEntry({
originalEntry,
reason,
hostValue: entry.isHole ? undefined : entry.hostValue
})
}
export function appendQuarantine(
hostNode: SubgraphNode,
entries: readonly ProxyWidgetErrorQuarantineEntry[]
): void {
if (entries.length === 0) return
const existing = parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
const merged = [...existing]
for (const candidate of entries) {
if (
!merged.some((e) => isEqual(e.originalEntry, candidate.originalEntry))
) {
merged.push(candidate)
}
}
if (merged.length === 0) delete hostNode.properties[QUARANTINE_PROPERTY]
else hostNode.properties[QUARANTINE_PROPERTY] = merged
}
export function readHostQuarantine(
hostNode: SubgraphNode
): ProxyWidgetErrorQuarantineEntry[] {
return parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
}
export function makeQuarantineEntry(args: {
originalEntry: SerializedProxyWidgetTuple
reason: ProxyWidgetQuarantineReason
hostValue?: TWidgetValue
}): ProxyWidgetErrorQuarantineEntry {
const entry: ProxyWidgetErrorQuarantineEntry = {
originalEntry: args.originalEntry,
reason: args.reason,
attemptedAtVersion: QUARANTINE_VERSION
}
if (args.hostValue !== undefined) {
entry.hostValue = args.hostValue
}
return entry
}

View File

@@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
import { resolvePreviewExposureChain } from './previewExposureChain'
const rootGraphA = 'root-a' as UUID
const rootGraphB = 'root-b' as UUID
interface FixtureExposure extends PreviewExposure {}
interface NestedHostMapping {
fromHostLocator: string
fromSourceNodeId: string
toRootGraphId: UUID
toHostLocator: string
}
function makeContext(
exposureMap: Map<string, FixtureExposure[]>,
nested: NestedHostMapping[]
): PreviewExposureChainContext {
return {
getExposures(rootGraphId, hostLocator) {
return exposureMap.get(`${rootGraphId}|${hostLocator}`) ?? []
},
resolveNestedHost(_rootGraphId, hostLocator, sourceNodeId) {
const match = nested.find(
(n) =>
n.fromHostLocator === hostLocator &&
n.fromSourceNodeId === sourceNodeId
)
if (!match) return undefined
return {
rootGraphId: match.toRootGraphId,
hostNodeLocator: match.toHostLocator
}
}
}
}
describe(resolvePreviewExposureChain, () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns undefined when the named exposure is not on the starting host', () => {
const ctx = makeContext(new Map(), [])
expect(
resolvePreviewExposureChain(rootGraphA, 'host-a', 'absent', ctx)
).toBeUndefined()
})
it('returns a single-step chain when the source is a leaf (no nested host)', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[
{
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'preview',
ctx
)
expect(result).toEqual({
steps: [
{
rootGraphId: rootGraphA,
hostNodeLocator: 'host-a',
exposure: {
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
}
],
leaf: {
rootGraphId: rootGraphA,
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
})
})
it('walks one nested host and returns a two-step chain', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer-preview',
sourceNodeId: '99',
sourcePreviewName: 'inner-preview'
}
]
],
[
`${rootGraphA}|host-inner`,
[
{
name: 'inner-preview',
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer-preview',
ctx
)
expect(result?.steps).toHaveLength(2)
expect(result?.steps[0].hostNodeLocator).toBe('host-outer')
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('walks two nested hosts (three-step chain) crossing a root graph boundary', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-1`,
[
{
name: 'p1',
sourceNodeId: 'sub-a',
sourcePreviewName: 'p2'
}
]
],
[
`${rootGraphA}|host-2`,
[
{
name: 'p2',
sourceNodeId: 'sub-b',
sourcePreviewName: 'p3'
}
]
],
[
`${rootGraphB}|host-3`,
[
{
name: 'p3',
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-1',
fromSourceNodeId: 'sub-a',
toRootGraphId: rootGraphA,
toHostLocator: 'host-2'
},
{
fromHostLocator: 'host-2',
fromSourceNodeId: 'sub-b',
toRootGraphId: rootGraphB,
toHostLocator: 'host-3'
}
])
const result = resolvePreviewExposureChain(rootGraphA, 'host-1', 'p1', ctx)
expect(result?.steps).toHaveLength(3)
expect(result?.steps.map((s) => s.exposure.name)).toEqual([
'p1',
'p2',
'p3'
])
expect(result?.leaf).toEqual({
rootGraphId: rootGraphB,
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('terminates at outer step when nested host has no matching exposure', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer',
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
}
]
],
[`${rootGraphA}|host-inner`, []]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer',
ctx
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
})
})
it('detects cycles, warns, and stops walking', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-a',
fromSourceNodeId: 'sub',
toRootGraphId: rootGraphA,
toHostLocator: 'host-a'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'cyclic',
ctx
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('cycle detected')
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf.sourceNodeId).toBe('sub')
})
})

View File

@@ -0,0 +1,105 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure
}
export interface ResolvedPreviewChain {
steps: readonly ResolvedPreviewChainStep[]
leaf: {
rootGraphId: UUID
sourceNodeId: string
sourcePreviewName: string
}
}
export interface PreviewExposureChainContext {
getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[]
resolveNestedHost(
rootGraphId: UUID,
hostNodeLocator: string,
sourceNodeId: string
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
}
const MAX_CHAIN_DEPTH = 32
export function resolvePreviewExposureChain(
rootGraphId: UUID,
hostNodeLocator: string,
name: string,
ctx: PreviewExposureChainContext
): ResolvedPreviewChain | undefined {
const steps: ResolvedPreviewChainStep[] = []
const visited = new Set<string>()
let currentRootGraphId: UUID = rootGraphId
let currentHost = hostNodeLocator
let currentName = name
const chainFromLastStep = (): ResolvedPreviewChain | undefined => {
if (steps.length === 0) return undefined
const lastStep = steps[steps.length - 1]
return {
steps,
leaf: {
rootGraphId: lastStep.rootGraphId,
sourceNodeId: lastStep.exposure.sourceNodeId,
sourcePreviewName: lastStep.exposure.sourcePreviewName
}
}
}
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
const key = `${currentRootGraphId}|${currentHost}|${currentName}`
if (visited.has(key)) {
console.warn(
`[previewExposureChain] cycle detected at ${key}; terminating walk`
)
return chainFromLastStep()
}
visited.add(key)
const exposure = ctx
.getExposures(currentRootGraphId, currentHost)
.find((e) => e.name === currentName)
if (!exposure) return chainFromLastStep()
steps.push({
rootGraphId: currentRootGraphId,
hostNodeLocator: currentHost,
exposure
})
const nested = ctx.resolveNestedHost(
currentRootGraphId,
currentHost,
exposure.sourceNodeId
)
if (!nested) {
return {
steps,
leaf: {
rootGraphId: currentRootGraphId,
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
}
}
currentRootGraphId = nested.rootGraphId
currentHost = nested.hostNodeLocator
currentName = exposure.sourcePreviewName
}
console.warn(
`[previewExposureChain] max chain depth (${MAX_CHAIN_DEPTH}) reached; terminating walk`
)
return chainFromLastStep()
}

View File

@@ -1,6 +1,7 @@
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 type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
@@ -10,20 +11,17 @@ export interface ResolvedPromotedWidget {
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,12 @@ import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
@@ -20,6 +22,9 @@ import {
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -33,14 +38,6 @@ interface SubgraphSlotRef {
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
@@ -56,7 +53,6 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
@@ -64,7 +60,6 @@ export function createPromotedWidgetView(
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
@@ -91,16 +86,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
@@ -116,6 +111,10 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
@@ -150,61 +149,78 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
this.setHostWidgetState(value)
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
if (didUpdateState) return
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getWidgetState()
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
@@ -212,20 +228,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
@@ -306,7 +316,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
@@ -351,14 +360,34 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
}
@@ -372,8 +401,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -413,9 +441,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
@@ -542,7 +568,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
}
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {

View File

@@ -9,7 +9,17 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
}
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -18,12 +28,19 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -34,6 +51,34 @@ function widget(
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
/**
* Builds a host SubgraphNode whose subgraph contains two source nodes that
* share a widget name (`text`), then promotes both — forcing the second
* promotion to be disambiguated to `text_1`.
*/
function buildDuplicateNamePromotion() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const nodeA = new LGraphNode('SourceA')
subgraph.add(nodeA)
const inputA = nodeA.addInput('text', 'STRING')
const widgetA = nodeA.addWidget('text', 'text', 'a', () => {})
inputA.widget = { name: widgetA.name }
const nodeB = new LGraphNode('SourceB')
subgraph.add(nodeB)
const inputB = nodeB.addInput('text', 'STRING')
const widgetB = nodeB.addWidget('text', 'text', 'b', () => {})
inputB.widget = { name: widgetB.name }
expect(promoteValueWidgetViaSubgraphInput(host, nodeA, widgetA).ok).toBe(true)
expect(promoteValueWidgetViaSubgraphInput(host, nodeB, widgetB).ok).toBe(true)
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text', 'text_1'])
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -112,58 +157,64 @@ describe('pruneDisconnected', () => {
vi.restoreAllMocks()
})
it('removes disconnected entries and emits a dev warning', () => {
it('removes disconnected linked inputs and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const keptInput = interiorNode.addInput('kept', 'STRING')
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
keptInput.widget = { name: keptWidget.name }
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
it('does not prune preview exposures for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
const hostLocator = String(subgraphNode.id)
usePreviewExposureStore().addExposure(
subgraphNode.rootGraph.id,
hostLocator,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
)
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -232,6 +283,50 @@ describe('promoteRecommendedWidgets', () => {
updatePreviewsMock.mockReset()
})
it('promotes recommended value widgets through linked subgraph inputs', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Sampler')
const input = interiorNode.addInput('seed', 'INT')
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
expect(linkedInput).toBeDefined()
expect(input.link).not.toBeNull()
expect(linkedInput?.linkIds).toContain(input.link)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const hostLocator = String(subgraphNode.id)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
expect(subgraph.inputs).toHaveLength(0)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -252,7 +347,7 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
@@ -261,36 +356,86 @@ describe('promoteRecommendedWidgets', () => {
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
const hostLocator = String(subgraphNode.id)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
describe('autoExposeKnownPreviewNodes', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
updatePreviewsMock.mockReset()
})
it('auto-exposes previews when host has no persisted previewExposures property', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
autoExposeKnownPreviewNodes(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toHaveLength(1)
})
it('does not auto-expose when host has empty persisted previewExposures (user cleared)', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.properties.previewExposures = []
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toEqual([])
})
it('does not auto-expose when host has non-empty persisted previewExposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
const otherNode = new LGraphNode('OtherShader')
otherNode.type = 'GLSLShader'
subgraph.add(otherNode)
subgraphNode.properties.previewExposures = [
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(otherNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
]
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
})
})
@@ -314,12 +459,11 @@ describe('hasUnpromotedWidgets', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
const input = interiorNode.addInput('seed', 'STRING')
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
input.widget = { name: widget.name }
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
@@ -416,3 +560,413 @@ describe('isLinkedPromotion', () => {
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
describe('reorderSubgraphInputsByName', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
expect(host.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
})
it('reorders promoted widgets on the host node from subgraph input order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
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)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
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)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
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)
reorderSubgraphInputsByName(host, ['second', 'first'])
const [secondSlot, firstSlot] = subgraph.inputs
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
expect(secondLink?.origin_slot).toBe(0)
expect(firstLink?.origin_slot).toBe(1)
})
it('updates outer link target_slot when host inputs are reordered', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(host)
const source = new LGraphNode('Source')
source.addOutput('out', 'STRING')
subgraph.rootGraph.add(source)
const firstLink = source.connect(0, host, 0)
const secondLink = source.connect(0, host, 1)
expect(firstLink?.target_slot).toBe(0)
expect(secondLink?.target_slot).toBe(1)
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(firstLink?.target_slot).toBe(1)
expect(secondLink?.target_slot).toBe(0)
})
})
describe('reorderSubgraphInputsByWidgetOrder', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('reorders duplicate-named promoted inputs by widget identity', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
})
describe('demoteWidget — axiomatic projection retraction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
function setupPromotedWidget() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
host.subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('value', 'STRING')
const interiorWidget = interiorNode.addWidget(
'text',
'value',
'initial',
() => {}
)
interiorInput.widget = { name: interiorWidget.name }
const result = promoteValueWidgetViaSubgraphInput(
host,
interiorNode,
interiorWidget
)
expect(result.ok).toBe(true)
return { host, interiorNode, interiorWidget }
}
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedViewsBefore = host.widgets.length
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedViewsBefore).toBeGreaterThan(0)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.inputs[0]?.link).toBe(9999)
expect(host.inputs[0]?._widget).toBeUndefined()
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
})
it('removes the slot entirely when host slot has no external link', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
expect(host.subgraph.inputs).toHaveLength(1)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(0)
expect(host.inputs).toHaveLength(0)
})
it('demotes the second of two promoted widgets sharing a source widget name', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
'text',
'text_1'
])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
false
)
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
true
)
})
})
describe('disambiguated nested promotion identity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
pruneDisconnected(host)
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.subgraph.inputs[0]?.name).toBe('text_1')
})
it('marks a promoted interior widget as computedDisabled so the connection dot replaces its UI', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
interiorNode.updateComputedDisabled()
expect(interiorWidget.computedDisabled).toBe(true)
})
it('preserves a real two-level promotion through the SubgraphEditor mount-time prune', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
const beforeCount = outerHost.subgraph.inputs.length
expect(beforeCount).toBe(2)
pruneDisconnected(outerHost)
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
})
})

View File

@@ -2,12 +2,13 @@ import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { reorderSubgraphInputs } from '@/lib/litegraph/src/subgraph/subgraphUtils'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -15,29 +16,36 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
return w.name
}
/**
* Returns true if the given promotion entry corresponds to a linked promotion
* on the subgraph node. Linked promotions are driven by subgraph input
* connections and cannot be independently hidden or shown.
*/
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
return (
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
undefined
)
}
export function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
return (
w &&
@@ -48,21 +56,125 @@ export function isLinkedPromotion(
})
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
): void {
const order = new Map(
orderedInputNames.map((name, index) => [name, index] as const)
)
const byOrder = <T extends { name: string }>(left: T, right: T) => {
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
}
const orderedIndices = subgraphNode.subgraph.inputs
.map((input, index) => ({ input, index }))
.sort((left, right) => byOrder(left.input, right.input))
.map(({ index }) => index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
}
return []
})
for (const index of remainingIndices) orderedIndices.push(index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
}
}
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
function isPreviewExposed(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
const hostLocator = String(subgraphNode.id)
return usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === source.sourceNodeId &&
exposure.sourcePreviewName === source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource,
widget?: IBaseWidget
): boolean {
if (widget && isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
}
}
@@ -74,18 +186,67 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
type CanonicalPromotionResult =
| { ok: true }
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
export function promoteValueWidgetViaSubgraphInput(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
return { ok: true }
}
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
const inputName = nextUniqueName(sourceWidgetName, existingNames)
const subgraphInput = subgraphNode.subgraph.addInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
return { ok: true }
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourcePreviewName: string
): void {
const store = usePreviewExposureStore()
const rootGraphId = subgraphNode.rootGraph.id
const hostLocator = String(subgraphNode.id)
const existing = store
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourcePreviewName
})
}
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
@@ -98,10 +259,21 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
if (!(node instanceof LGraphNode)) return
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, source)
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(parent, node, source.sourceWidgetName)
continue
}
const result = promoteValueWidgetViaSubgraphInput(parent, node, widget)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
})
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -116,10 +288,45 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, source)
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
const hostLocator = String(parent.id)
const exposure = previewStore
.getExposures(parent.rootGraph.id, hostLocator)
.find(
(entry) =>
entry.sourceNodeId === source.sourceNodeId &&
entry.sourcePreviewName === source.sourceWidgetName
)
if (exposure) {
previewStore.removeExposure(
parent.rootGraph.id,
hostLocator,
exposure.name
)
continue
}
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -152,11 +359,10 @@ export function addWidgetPromotionOptions(
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
)
if (promotableParents.length > 0)
options.unshift({
@@ -189,10 +395,9 @@ export function tryToggleWidgetPromotion() {
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -247,8 +452,8 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
export function autoExposeKnownPreviewNodes(subgraphNode: SubgraphNode): void {
if (subgraphNode.properties.previewExposures !== undefined) return
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -260,88 +465,86 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
promotePreviewViaExposure(subgraphNode, node, widget.name)
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
promotePreviewWidget()
// If a preview widget already exists in this frame, there's nothing to
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
canvasSource
)
) {
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
}
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
autoExposeKnownPreviewNodes(subgraphNode)
const interiorNodes = subgraphNode.subgraph.nodes
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
for (const [n, w] of filteredWidgets) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
toPromotionSource(n, w)
)
const result = promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
})
}
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.sourceNodeId)
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.sourceWidgetName
(iw) => iw.name === widget.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
removedEntries.push(widget)
}
return hasWidget
return !hasWidget
})
for (const input of staleInputs) {
subgraph.removeInput(input)
}
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[proxyWidgetUtils] Pruned disconnected promotions',
'[subgraphInputs] Pruned disconnected promoted widget inputs',
removedEntries,
{
graphId: subgraphNode.rootGraph.id,
@@ -350,27 +553,29 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
getPromotableWidgets(interiorNode).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
!isWidgetPromotedOnSubgraphNode(
subgraphNode,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
},
widget
)
)
)
}

View File

@@ -30,7 +30,6 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -52,8 +51,7 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -63,7 +61,6 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -97,27 +94,6 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,40 +20,29 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
let nextHostUid = 0
const visitedByHost = new WeakMap<SubgraphNode, Set<string>>()
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
if (hostUid === undefined) {
hostUid = nextHostUid
nextHostUid += 1
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
const key = `${currentNodeId}:${currentWidgetName}`
const visited = visitedByHost.get(currentHost) ?? new Set<string>()
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
visited.add(key)
visitedByHost.set(currentHost, visited)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -73,42 +62,20 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
@@ -117,11 +84,27 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -1,23 +0,0 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -85,7 +85,7 @@ describe('resolveSubgraphInputTarget', () => {
})
})
test('returns undefined for non-widget input on nested SubgraphNode', () => {
test('resolves non-widget input on nested SubgraphNode to immediate child target', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'audio'
])
@@ -93,28 +93,41 @@ describe('resolveSubgraphInputTarget', () => {
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
expect(result).toBeUndefined()
expect(result).toMatchObject({
nodeId: '819',
widgetName: 'audio'
})
})
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
test('resolves both widget and non-widget inputs on nested SubgraphNodes', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
const { innerSubgraphNode: widthChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'width',
'width',
{ widget: 'width' }
)
const { innerSubgraphNode: audioChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'audio',
'audio'
)
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '819',
nodeId: String(widthChild.id),
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
).toMatchObject({
nodeId: String(audioChild.id),
widgetName: 'audio'
})
})
test('returns target for widget-backed input on plain interior node', () => {
@@ -199,7 +212,10 @@ describe('resolveSubgraphInputTarget', () => {
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
).toMatchObject({
nodeId: '820',
widgetName: 'audio'
})
})
test('three-level nesting returns immediate child target, not deepest', () => {

View File

@@ -1,12 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -18,19 +16,6 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -1,408 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
// Both views share the widget name; they're distinguished by sourceNodeId
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
expect(subgraphNode.widgets[1].name).toBe('widgetB')
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
// View resolves the interior widget's type
expect(subgraphNode.widgets[0].type).toBe('text')
// Remove interior widget — view falls back to disconnected state
innerNodes[0].widgets.pop()
expect(subgraphNode.widgets[0].type).toBe('button')
// Re-add — view resolves again
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
const widgetToRemove = subgraphNode.widgets[0]
subgraphNode.removeWidget(widgetToRemove)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('removeWidget removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
subgraphNode.removeWidget(widgetA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
// Simulate an input referencing the widget
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
subgraphNode.removeWidget(view)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
subgraphNode.graph!.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('shared_input', '*')
nodeA.addWidget('text', 'shared_input', 'first', () => {})
inputA.widget = { name: 'shared_input' }
subgraph.add(nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('shared_input', '*')
nodeB.addWidget('text', 'shared_input', 'second', () => {})
inputB.widget = { name: 'shared_input' }
subgraph.add(nodeB)
const nodeC = new LGraphNode('NodeC')
const inputC = nodeC.addInput('shared_input', '*')
nodeC.addWidget('text', 'shared_input', 'third', () => {})
inputC.widget = { name: 'shared_input' }
subgraph.add(nodeC)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
subgraph.inputNode.slots[0].connect(inputB, nodeB)
subgraph.inputNode.slots[0].connect(inputC, nodeC)
const firstRead = subgraphNode.widgets.map((w) => w.value)
const secondRead = subgraphNode.widgets.map((w) => w.value)
const thirdRead = subgraphNode.widgets.map((w) => w.value)
expect(firstRead).toStrictEqual(secondRead)
expect(secondRead).toStrictEqual(thirdRead)
expect(subgraphNode.widgets[0].value).toBe('first')
})
test('3-level nested promotion resolves concrete widget type and value', () => {
usePromotionStore()
// Level C: innermost subgraph with a concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'deep_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('deep_input', '*')
concreteNode.addWidget('number', 'deep_input', 42, () => {})
concreteInput.widget = { name: 'deep_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
// Level B: middle subgraph containing C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'mid_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
// Level A: outermost subgraph containing B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'outer_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
// Outermost promoted widget should resolve through all 3 levels
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
const view = subgraphNode.widgets[0]
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
// Remove: should clean up store AND input reference
subgraphNode.removeWidget(view)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(0)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote: should work correctly after cleanup
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('text')
expect(subgraphNode.widgets[0].value).toBe('value')
})
})

View File

@@ -11,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -22,6 +23,7 @@ import {
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const INLINE_INPUTS = false
@@ -185,11 +187,18 @@ function dynamicComboWidget(
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
Object.defineProperty(widget, 'value', {
get() {
return widgetValue
return getState()?.value ?? widgetValue
},
set(value) {
const state = getState()
if (state) state.value = value
widgetValue = value
updateWidgets(value)
}

View File

@@ -0,0 +1,32 @@
import type { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
/**
* @param property - Raw node property value.
* @param schema - Zod schema describing the expected array shape.
* @param contextName - Prefix for `console.warn` messages (e.g. `properties.proxyWidgets`).
*/
export function parseNodePropertyArray<T>(
property: NodeProperty | undefined,
schema: z.ZodType<T[]>,
contextName: string
): T[] {
if (property === undefined) return []
let parsed: unknown
try {
parsed = typeof property === 'string' ? JSON.parse(property) : property
} catch (e) {
console.warn(`Failed to parse ${contextName}:`, e)
return []
}
const result = schema.safeParse(parsed)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for ${contextName}:\n${error}`)
return []
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import { parsePreviewExposures } from './previewExposureSchema'
describe(parsePreviewExposures, () => {
it('parses a valid array of preview exposure objects', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'preview2',
sourceNodeId: '7',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(input)).toEqual(input)
})
it('parses JSON-string input', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
})
it('returns empty array for undefined', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parsePreviewExposures(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('returns empty array for malformed JSON string', () => {
expect(parsePreviewExposures('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(
parsePreviewExposures({
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
})
).toEqual([])
expect(parsePreviewExposures(42)).toEqual([])
})
it('returns empty array when entries are missing required fields', () => {
expect(
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
).toEqual([])
expect(
parsePreviewExposures([
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
])
).toEqual([])
})
it('returns empty array when entries have wrong types', () => {
expect(
parsePreviewExposures([
{
name: 123,
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
expect(
parsePreviewExposures([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
})
})

View File

@@ -0,0 +1,24 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
return parseNodePropertyArray(
property,
previewExposuresPropertySchema,
'properties.previewExposures'
)
}

View File

@@ -1,29 +1,25 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetTupleSchema = z.union([
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const serializedProxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
return parseNodePropertyArray(
property,
proxyWidgetsPropertySchema,
'properties.proxyWidgets'
)
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
const baseEntry = {
originalEntry: ['10', 'seed'] as [string, string],
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
attemptedAtVersion: 1 as const
}
describe(parseProxyWidgetErrorQuarantine, () => {
it.for([
{ name: 'without hostValue', entry: baseEntry },
{ name: 'with hostValue', entry: { ...baseEntry, hostValue: 42 } },
{
name: 'with 3-tuple originalEntry',
entry: { ...baseEntry, originalEntry: ['3', 'text', '1'] }
}
])('parses a valid entry $name', ({ entry }) => {
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it.for([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
] as const)('parses reason %s', (reason) => {
const entry = { ...baseEntry, reason }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses JSON-string input', () => {
const input = JSON.stringify([baseEntry])
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
})
it('returns empty array for undefined without warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it.for([
{ name: 'malformed JSON string', input: 'not-json{' },
{ name: 'non-array input', input: baseEntry },
{
name: 'attemptedAtVersion is not 1',
input: [{ ...baseEntry, attemptedAtVersion: 2 }]
},
{
name: 'reason is not in the enum',
input: [{ ...baseEntry, reason: 'somethingElse' }]
},
{
name: 'originalEntry is malformed',
input: [{ ...baseEntry, originalEntry: ['only-one'] }]
}
])('returns empty array when $name', ({ input }) => {
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([])
})
})

View File

@@ -0,0 +1,45 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
])
export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),
attemptedAtVersion: z.literal(1)
})
const proxyWidgetErrorQuarantinePropertySchema = z.array(
proxyWidgetErrorQuarantineEntrySchema
)
export type ProxyWidgetErrorQuarantineEntry = Omit<
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
'hostValue'
> & { hostValue?: TWidgetValue }
export function parseProxyWidgetErrorQuarantine(
property: NodeProperty | undefined
): ProxyWidgetErrorQuarantineEntry[] {
return parseNodePropertyArray(
property,
proxyWidgetErrorQuarantinePropertySchema,
'properties.proxyWidgetErrorQuarantine'
) as ProxyWidgetErrorQuarantineEntry[]
}

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**

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