Compare commits

..

44 Commits

Author SHA1 Message Date
Comfy Org PR Bot
f754141537 [backport core/1.43] Fix pruning of uninitialized promoted primitives (#11990)
Backport of #11987 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11990-backport-core-1-43-Fix-pruning-of-uninitialized-promoted-primitives-3576d73d36508145ba8eea92fb5dc03a)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-05 21:46:29 +00:00
Comfy Org PR Bot
9e4ce35fb1 1.43.17 (#11989)
Patch version increment to 1.43.17

**Base branch:** `core/1.43`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11989-1-43-17-3576d73d36508182b82cde34dfb9ed26)
by [Unito](https://www.unito.io)

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
2026-05-05 21:45:38 +00:00
Alexander Brown
900c168b69 [backport core/1.43] fix: subgraph fitView pair (#10995 + #11240) (#11861)
*PR Created by the Glary-Bot Agent*

---

Manual backport of **#10995** + **#11240** to `core/1.43` for inclusion
in `v1.43.16`. Two commits shipped together because #11240 fixes a
regression introduced by #10995.

## Why
- **#10995** (`577f373cd`) — Auto fit-to-view on first subgraph entry.
Without this, inner subgraph nodes can be off-screen on first
navigation. On cache miss in `restoreViewport()`, calls `fitView()` via
`requestAnimationFrame` instead of silently returning. Existing
cache-hit path unchanged.
- **#11240** (`394e36984`) — Re-sync collapsed node slot positions after
subgraph fitView. The new `fitView()` call from #10995 changes canvas
scale/offset, invalidating cached slot positions for collapsed nodes
(links rendered at wrong positions). Schedules
`requestSlotLayoutSyncForAllNodes()` on the next frame after `fitView()`
so positions are re-measured against the updated transform.

These must ship together — splitting them would land #10995 with a known
link-rendering regression.

## Conflict resolution
- **#10995**: clean cherry-pick.
- **#11240**: binary add/add conflict on
`browser_tests/tests/collapsedNodeLinks.spec.ts-snapshots/subgraph-entry-collapsed-node-links-chromium-linux.png`.
Both `core/1.43` (via earlier backport #11788 'inline node footer
layout') and #11240 add this screenshot with slightly different content.
Kept #11240's version — it's the reference image its updated assertions
expect.

## Validation
- `pnpm typecheck` 
- `pnpm test:unit -- run
src/stores/subgraphNavigationStore.viewport.test.ts`  (14/14 passing)
- `pnpm exec eslint <changed files>`  (0 errors)
- `pnpm exec oxfmt --check`  (clean)

Manual end-to-end verification not performed — subgraph navigation
requires loading workflows with subgraphs in a live browser. Both source
commits are byte-identical to upstream and have been baking on `main`
for ~3 weeks through `v1.44.x`.

Original PRs: #10995, #11240 / Original commits: `577f373cd`,
`394e36984`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11861-backport-core-1-43-fix-subgraph-fitView-pair-10995-11240-3556d73d365081bd9b04c77ef28195ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-05 18:56:38 +00:00
Comfy Org PR Bot
bbb76f8b38 [backport core/1.43] fix(i18n): rename OpenAI GPT Image 1 to GPT Image 2 across locales (#11978)
Backport of #11968 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11978-backport-core-1-43-fix-i18n-rename-OpenAI-GPT-Image-1-to-GPT-Image-2-across-locales-3576d73d3650815cb245c280f5829553)
by [Unito](https://www.unito.io)

Co-authored-by: Marwan Ahmed <155799754+marawan206@users.noreply.github.com>
Co-authored-by: Marwan Ahmed <marwan@Marwans-MacBook-Pro.local>
2026-05-05 18:19:17 +00:00
jaeone94
ddab5234b9 [backport core/1.43] fix: skip nested subgraph containers in replay scan (#11962)
## Summary
- Backports #11908 to `core/1.43`.
- Keeps the shared runtime fix in
`src/composables/graph/useErrorClearingHooks.ts`.
- Keeps the focused unit coverage in
`src/composables/graph/useErrorClearingHooks.test.ts`.
- Drops the E2E-only change to
`browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
from this stable backport.

## Backport notes
- Auto-backport is not practical here because #11908 was stacked on the
#11907 regression-test PR. Cherry-picking #11908 into `core/1.43` pulls
a modify/delete conflict for the E2E file that does not exist on this
branch.
- Backporting #11907 first would require newer browser-test
fixture/test-infra changes and would add stable-branch test churn that
is unrelated to the runtime fix.
- This is still safe because main keeps the Cloud E2E regression
coverage from #11907/#11908, while this backport carries the production
fix plus unit coverage for the shared replay-scan behavior.

## Verification
- `pnpm typecheck` (pre-commit hook)
- `pnpm exec vitest run
src/composables/graph/useErrorClearingHooks.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11962-backport-core-1-43-fix-skip-nested-subgraph-containers-in-replay-scan-3576d73d365081aaa686e2637c5a1fee)
by [Unito](https://www.unito.io)
2026-05-05 11:10:07 -07:00
Comfy Org PR Bot
a596c60c33 [backport core/1.43] Fix core node detection for missing nodes (#11931)
Backport of #11809 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11931-backport-core-1-43-Fix-core-node-detection-for-missing-nodes-3566d73d365081c3bd09f88d38f49353)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-04 22:16:20 +00:00
Comfy Org PR Bot
380d4c9002 [backport core/1.43] fix: reset file input value after selection to allow same-file reupload (#11505)
Backport of #11417 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11505-backport-core-1-43-fix-reset-file-input-value-after-selection-to-allow-same-file-reup-3496d73d36508192848bdf653c6b22ae)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 21:43:11 +00:00
Comfy Org PR Bot
870871e2fa [backport core/1.43] feat: enable node replacement by default (#11449)
Backport of #11439 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11449-backport-core-1-43-feat-enable-node-replacement-by-default-3486d73d365081feb74ec1dafcb3fb13)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:39:54 -07:00
jaeone94
e047b12556 [backport core/1.43] fix: use hash lookup for missing asset detection (#11873) (#11890)
Manual backport of #11873 to `core/1.43`.

Cherry-picked squash merge commit
`04918360ebcf69adffdf5d8ff6603c24c7a2809a`.

## Why

#11873 moves missing asset detection to use exact BLAKE3 hash lookups
first when a workflow exposes a valid hash, then falls back to the
legacy asset-list matching path for non-hash, invalid, or transiently
unverifiable candidates.

For media inputs, the legacy fallback now reads from a separate
public-inclusive input asset cache in `assetService` instead of reusing
`assetsStore.inputAssets`. That keeps the UI/widget asset list user-only
while allowing missing input detection to account for public cloud
assets loaded by templates, avoiding false-positive missing media on
initial workflow load.

## Conflict resolution

- `src/platform/assets/services/assetService.test.ts`: `core/1.43` only
had the existing `shouldUseAssetBrowser` coverage, while #11873 adds
asset hash, upload validation, pagination, and public-inclusive input
cache tests. Kept the #11873 test expansion; the existing test coverage
is preserved in the incoming file.
- `src/stores/assetsStore.test.ts`: expanded the mocked `assetService`
surface and kept #11873's appended store tests for asset metadata/tag
updates, deletion state, input name mapping, and input cache
invalidation. The runtime `assetsStore.ts` changes auto-merged cleanly.
- All production files auto-merged cleanly.

## Validation

- `pnpm test:unit -- run
src/platform/assets/services/assetService.test.ts
src/stores/assetsStore.test.ts`  (91/91 passing)
- `pnpm typecheck` 
- `pnpm exec oxfmt --check src/platform/assets/services/assetService.ts
src/platform/assets/services/assetService.test.ts
src/platform/missingMedia/missingMediaScan.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingModel/missingModelScan.ts
src/platform/missingModel/missingModelScan.test.ts
src/stores/assetsStore.ts src/stores/assetsStore.test.ts` 
- `git diff --check` 
- Commit hook also ran `oxfmt`, `oxlint`, `eslint`, and `pnpm typecheck`
on the changed files 
- Push hook ran `pnpm knip --cache` 

Original PR: #11873
Original commit: `04918360ebcf69adffdf5d8ff6603c24c7a2809a`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11890-backport-core-1-43-fix-use-hash-lookup-for-missing-asset-detection-11873-3566d73d365081c9a76dfa3cd367122c)
by [Unito](https://www.unito.io)
2026-05-04 14:12:00 -07:00
Comfy Org PR Bot
e1bdaa5778 [backport core/1.43] refactor: extract missing model refresh pipeline (#11887)
Backport of #11751 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11887-backport-core-1-43-refactor-extract-missing-model-refresh-pipeline-3566d73d365081f687aafe82b655f135)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-04 06:15:33 +00:00
Alexander Brown
e8b5e92c48 [backport core/1.43] fix: ensure escape key/graph navigation cancels ghost node placement (#11779) (#11860)
*PR Created by the Glary-Bot Agent*

---

Manual backport of #11779 to `core/1.43` for inclusion in `v1.43.16`.
Cherry-picked from upstream merge commit `b8dfbfc0b`.

## Why
When inside a subgraph, the parent key handler intercepts the escape key
before `processKey` is called, causing a stale ghost node to be added to
the inner subgraph when the graph is changed to the parent. Moves escape
handler to document, prevents propagation, and adds a safety-net cancel
in `setGraph`.

## Conflict resolution
- `browser_tests/tests/nodeGhostPlacement.spec.ts`: PR adds 6 new test
cases inside the existing `describe` block. Conflict was
non-overlapping/additive — accepted all PR additions (existing tests
untouched). All referenced helpers (`comfyPage.subgraph`,
`Comfy.Graph.ConvertToSubgraph`, `Comfy.PublishSubgraph`,
`comfyPage.visibleToasts`) verified present on `core/1.43`.
- Test file rename `LGraphCanvas.ghostAutoPan.test.ts` →
`LGraphCanvas.ghost.test.ts` (intentional consolidation in PR) carried
through cleanly.

## Validation
- `pnpm typecheck` 
- `pnpm test:unit -- run
src/lib/litegraph/src/LGraphCanvas.ghost.test.ts`  (11/11 passing)
- `pnpm exec eslint <changed files>`  (0 errors)
- `pnpm exec oxfmt --check`  (clean)

Source identical to upstream `b8dfbfc0b`, baking on `main` through
`v1.44.x`.

Original PR: #11779 / Original commit: `b8dfbfc0b`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11860-backport-core-1-43-fix-ensure-escape-key-graph-navigation-cancels-ghost-node-placemen-3556d73d3650819baf2fd23c6fb260bf)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-03 08:14:54 +00:00
Alexander Brown
92596a0a01 [backport core/1.43] fix cloud frontend runtime guard regressions (#11180) (#11857)
*PR Created by the Glary-Bot Agent*

---

Manual backport of #11180 to `core/1.43` for inclusion in `v1.43.16`.
Cherry-picked from upstream merge commit `9599a4e00`.

Hardens 3 runtime crash paths reproducing on `cloud.comfy.org`:
- widget propagation crash when `this.widgets[0]` is missing
(CLOUD-FRONTEND-STAGING-429)
- ignored stale autogrow disconnect callbacks after group removal
- treats nullish executed outputs as empty during flatten/parsing

## Conflict resolution
- Dropped
`src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.test.ts`.
PR #11180 modified this test file but it was added on `main` by
unrelated PR #11443 which is **not** backported. Runtime fixes are in
source files; coverage exists in the targeted unit tests this PR adds.

## Validation
- `pnpm typecheck` 
- `pnpm test:unit` on changed test files  (42/42 passing)
- `pnpm exec eslint <changed files>`  (0 errors)
- `pnpm exec oxfmt --check`  (clean)

Manual end-to-end verification not performed — these are crash-guard
fixes that require specific cloud-staging conditions to reproduce.
Source changes are byte-identical to upstream `9599a4e00` (baking on
`main` for ~3 weeks through `v1.44.x`).

Original PR: #11180 / Original commit: `9599a4e00`

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-03 01:01:31 -07:00
Alexander Brown
649857c4e2 [backport core/1.43] fix: trigger Vue reactivity on output slot type changes in matchType (#9935) (#11862)
*PR Created by the Glary-Bot Agent*

---

Manual backport of #9935 to `core/1.43` for inclusion in `v1.43.16`.
Cherry-picked from upstream merge commit `2fea0aa53` cleanly.

## Why
VHS unbatch output slot color did not update when slot types changed via
`matchType` resolution in the Vue renderer. After `changeOutputType`
mutates `output.type` on objects inside a `shallowReactive` array,
spread-copies `this.outputs` to trigger the shallowReactive setter so
`SlotConnectionDot` re-evaluates the slot color.

## Conflict resolution
None — clean cherry-pick. 1 file, +4 lines.

## Validation
- `pnpm typecheck` 
- `pnpm exec eslint src/core/graph/widgets/dynamicWidgets.ts`  (0
errors)
- `pnpm exec oxfmt --check`  (clean)

Source identical to upstream `2fea0aa53`, baking on `main` for ~2 weeks
through `v1.44.x`.

Original PR: #9935 / Original commit: `2fea0aa53`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11862-backport-core-1-43-fix-trigger-Vue-reactivity-on-output-slot-type-changes-in-matchTyp-3556d73d365081678f67c9949ccd4bb7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-03 01:01:15 -07:00
Alexander Brown
df0b670c77 [backport core/1.43] fix: stop duplicate node creation when dropping image on Vue nodes (#11541) (#11856)
*PR Created by the Glary-Bot Agent*

---

Manual backport of #11541 to `core/1.43` for inclusion in `v1.43.16`.

Cherry-picked from upstream merge commit `b23283144`.

## Why

`handleDrop` checked `handled === true` to gate `stopPropagation`, but
`onDragDrop` from `useNodeDragAndDrop` is async and always returns a
Promise — so the check never matched. The drop then bubbled to the
document handler in `app.ts` and spawned a new LoadImage node in
addition to the one that accepted the drop.

The fix moves the `preventDefault()`/`stopPropagation()` claim *into*
`onDragDrop` itself, gated by a new optional `claimEvent` flag.

## Conflict resolution

- **`src/composables/node/useNodeDragAndDrop.ts`** — `core/1.43` had the
older inline `node.onDragDrop = async function (e: DragEvent) { ... }`
form. Took #11541's full refactored version (named `installedDragDrop`
const + `onRemoved` cleanup via `useChainCallback`). `useChainCallback`
already exists on `core/1.43`. The new `(e, claimEvent = false)`
signature is backward-compatible with all existing callers.
- **`src/composables/node/useNodeDragAndDrop.test.ts`** — PR modified
this test file but the file was added on `main` by unrelated PR #11417
(`fix: reset file input value...`) which is **not** backported. Dropped
the test file from this backport. Runtime fix is intact; coverage exists
in `LGraphNode.test.ts` for the `claimEvent=true` call site.
- **`LGraphNode.{vue,test.ts}`, `litegraph-augmentation.d.ts`** —
auto-merged cleanly.

## Backport-only compatibility fix (added after code review)

The upstream PR removed `handleDrop`'s legacy `handled === true`
sync-return check in `LGraphNode.vue`. On `main` that's safe — all
in-repo `onDragDrop` handlers participate in the new `claimEvent` flag.
On `core/1.43` this is a public LiteGraph extension callback, and
custom-node packages may have synchronous `onDragDrop` implementations
that return `true` without honoring the new optional second argument.
Without the fallback, those drops would still bubble to the document
handler in `app.ts` and create duplicate nodes — the very bug this PR is
fixing.

Restored the legacy `handled === true` synchronous-claim path in
`handleDrop()` while keeping the new `claimEvent=true` call:
- async handlers from `useNodeDragAndDrop` claim the event themselves
- sync handlers returning `true` still get their event claimed
- handlers that return `false`/`undefined` still bubble (correct)

## Validation

- `pnpm typecheck` 
- `pnpm test:unit -- run
src/renderer/extensions/vueNodes/components/LGraphNode.test.ts`  (15/15
passing)
- `pnpm exec eslint <changed files>`  (0 errors)
- `pnpm exec oxfmt --check <changed files>`  (clean)

Manual end-to-end verification (Playwright) was not performed because
the duplicate-node-creation bug requires a live ComfyUI backend with
model loading and real drag-drop interaction on an actual canvas — not
reproducible in the sandbox. The fix is byte-identical to upstream
`b23283144` (which has been baking on `main` for ~1 week through
`v1.44.x`) plus the backport-only sync-handler compatibility shim
documented above.

Original PR: #11541
Original commit: `b23283144`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11856-backport-core-1-43-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes--3556d73d36508165b811f2f4ab3ae749)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-05-03 01:01:02 -07:00
Alexander Brown
a035ea913b [backport core/1.43] fix: disable pointer events on non-visible DOM widget overlays (#11063) (#11859)
*PR Created by the Glary-Bot Agent*

---

Manual backport of #11063 to `core/1.43` for inclusion in `v1.43.16`.
Cherry-picked from upstream merge commit `4cf160d66`.

## Why
When a node with DOM widget overlays (e.g. CLIPTextEncode) is collapsed,
the overlay elements can intercept pointer events intended for the
canvas collapse toggler, making click-to-expand unreliable. Adds
`!widgetState.visible` to the `pointerEvents` condition in
`composeStyle()`, immediately setting `pointer-events: none` when the
widget becomes invisible — closing the timing gap before `v-show`
applies `display: none`. Fixes upstream issue #11006.

## Conflict resolution
- Skipped the change to `browser_tests/tests/interaction.spec.ts`. PR
#11063 removed a programmatic-collapse workaround introduced by
unrelated PR #10967 (which is **not** on `core/1.43`). On 1.43 the test
uses an older `delay(1000) + click` form that doesn't need adjustment —
the source fix in `DomWidget.vue` makes the second click reliable on its
own. Kept the source + unit-test changes.

## Validation
- `pnpm typecheck` 
- `pnpm test:unit -- run src/components/graph/widgets/DomWidget.test.ts`
 (2/2 passing)
- `pnpm exec eslint <changed files>`  (0 errors)
- `pnpm exec oxfmt --check`  (clean)

Manual end-to-end verification not performed — DOM widget pointer-event
timing requires live canvas+widget interaction, not feasible to
reproduce reliably in headless. Source identical to upstream
`4cf160d66`, baking on `main` for ~3 weeks through `v1.44.x`.

Original PR: #11063 / Original commit: `4cf160d66`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11859-backport-core-1-43-fix-disable-pointer-events-on-non-visible-DOM-widget-overlays-11-3556d73d365081cab95ef32c17c0401e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-03 00:55:57 -07:00
jaeone94
45283226ef [backport core/1.43] fix: hide advanced footer button on collapsed Vue nodes (#11797)
Manual backport of #11778 to `core/1.43`.

Cherry-picked merge commit `46ba65e25cbfbd8214aec8b61951b77aa2db19e5`.

## Conflict resolution

- `browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts`: kept
#11778's `SHOW_ADVANCED_INPUTS`/`HIDE_ADVANCED_INPUTS` constants and
collapsed-node regression test, resolving only the assertion text/style
drift from `core/1.43`.
- `src/renderer/extensions/vueNodes/components/LGraphNode.test.ts`: kept
the `core/1.43` `setActivePinia` setup, added #11778's `useSettingStore`
mock/i18n entries and collapsed advanced footer tests, and did not bring
the unrelated `app` import from later main drag/drop work.
- `src/renderer/extensions/vueNodes/components/LGraphNode.vue`: #11778's
runtime guard applied cleanly (`isCollapsed` hides the advanced footer
button).

## Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit -- run
src/renderer/extensions/vueNodes/components/LGraphNode.test.ts`
- `git diff --check`
- Commit/push hooks also ran lint-staged formatting/lint checks and
`pnpm knip`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11797-backport-core-1-43-fix-hide-advanced-footer-button-on-collapsed-Vue-nodes-3536d73d3650815db910f4772a29d008)
by [Unito](https://www.unito.io)
2026-05-01 14:29:04 +09:00
jaeone94
7de0e2c4c1 [backport core/1.43] refactor: inline node footer layout to fix selection bounding box (#11788)
Manual backport of #10741 to `core/1.43`.

Cherry-picked merge commit `b157182a203671f4ea3bfa45ee8c30ae6b725676`.

## Conflict resolution

-
`browser_tests/tests/collapsedNodeLinks.spec.ts-snapshots/subgraph-entry-collapsed-node-links-chromium-linux.png`:
`core/1.43` did not have this screenshot expectation, while #10741 adds
the subgraph collapsed-node-link screenshot test. Kept the PR version.

## Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit -- run src/lib/litegraph/src/LGraphNode.test.ts
src/renderer/extensions/vueNodes/components/LGraphNode.test.ts
src/renderer/extensions/vueNodes/components/NodeFooter.test.ts
src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts`
- `git diff --check`
- Commit/push hooks also ran lint-staged formatting/lint checks and
`pnpm knip`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11788-backport-core-1-43-refactor-inline-node-footer-layout-to-fix-selection-bounding-box-3536d73d36508184969bf3ad90647e83)
by [Unito](https://www.unito.io)
2026-04-30 20:34:22 -07:00
Comfy Org PR Bot
7b152a680f [backport core/1.43] fix: detach DOM widget event listeners on widget removal (#11791)
Backport of #11724 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11791-backport-core-1-43-fix-detach-DOM-widget-event-listeners-on-widget-removal-3536d73d3650813d8193efc5c4aa225f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-30 19:47:16 -07:00
Comfy Org PR Bot
3a701c8307 [backport core/1.43] Fix legacy widget width in app mode (#11781)
Backport of #11574 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11781-backport-core-1-43-Fix-legacy-widget-width-in-app-mode-3526d73d36508127bce0e96b3d37304f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-04-30 17:07:42 +00:00
Comfy Org PR Bot
b2eedab53e [backport core/1.43] Fix node library action buttons (#11764)
Backport of #11232 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11764-backport-core-1-43-Fix-node-library-action-buttons-3516d73d3650813eae38d0a1de4c6f7c)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-04-29 17:15:34 +00:00
Comfy Org PR Bot
3c60f3ab6f [backport core/1.43] feat: refresh missing models through pipeline (#11707)
Backport of #11661 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11707-backport-core-1-43-feat-refresh-missing-models-through-pipeline-3506d73d36508175bec6f73b6fe2a8f4)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-04-28 00:30:11 +00:00
Comfy Org PR Bot
f7619013c1 [backport core/1.43] feat: add inline-CTA nightly survey for error panel (#11703)
Backport of #11591 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11703-backport-core-1-43-feat-add-inline-CTA-nightly-survey-for-error-panel-3506d73d3650816f99aeeb6a46203691)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-04-28 00:19:24 +00:00
Comfy Org PR Bot
bbea4cee69 1.43.16 (#11693)
Patch version increment to 1.43.16

**Base branch:** `core/1.43`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11693-1-43-16-34f6d73d365081098d45c53ce573dbff)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-04-27 13:54:47 -07:00
pythongosssss
9fb4cf3da4 [backport core/1.43] fix: fix webcam node not showing preview in nodes 2.0 (#11599)
Backport of #11549 to `core/1.43`.

Cherry-picked only the `src/extensions/core/webcamCapture.ts` change
from merge commit ac728b92ae. The new
tests and test-file modifications from the original PR were
intentionally excluded per backport instructions.

## Summary
- call `setNodePreviewsByNodeId` alongside `node.imgs = [img]` so the
captured image renders in nodes 2.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11599-backport-core-1-43-fix-fix-webcam-node-not-showing-preview-in-nodes-2-0-34c6d73d365081caa9baf91feeb468ee)
by [Unito](https://www.unito.io)
2026-04-24 13:59:05 -07:00
Christian Byrne
d1fa816934 [backport core/1.43] fix(manager): migrate 4 endpoints GET→POST for CSRF hardening (#11588)
Backport of #11520 to `core/1.43`.

Cherry-picked merge commit `bd96bdf4cc7a8c1460ca5d37cc2c856f4f1654b9`.

**Conflict resolution:**
- `browser_tests/tests/dialogs/managerDialog.spec.ts`: file was deleted
in `core/1.43` but modified in the original PR — accepted the PR version
per backport policy.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11588-backport-core-1-43-fix-manager-migrate-4-endpoints-GET-POST-for-CSRF-hardening-34c6d73d365081d8b1f4fc9607fa2dbd)
by [Unito](https://www.unito.io)

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-24 12:30:12 -07:00
Alexander Brown
4d91b745c7 [backport core/1.43] ci: filter e2e workflow + add e2e-status gate (#11592)
Backport of #11568 to `core/1.43`.

## Changes

Applies the e2e workflow filtering changes from #11568 to the
`core/1.43` release branch:

- **ci-tests-e2e.yaml**: Removed `paths-ignore` from `pull_request`
trigger, added `changes` job using `dorny/paths-filter` to detect
e2e-relevant file changes, added `needs: changes` and `should_run`
conditions to `setup`, `merge-reports`, `comment-on-pr-start`, and
`deploy-and-comment` jobs.
- **ci-tests-e2e-forks.yaml**: Switched `download-artifact` from
`actions/download-artifact@v7` to
`dawidd6/action-download-artifact@v12`, added `if_no_artifact_found:
warn`, added `hashFiles('reports/**') != ''` condition to `Handle Test
Completion`.
- **ci-tests-e2e-coverage.yaml**: Skipped — file does not exist on
`core/1.43`.

## Conflict Resolution

- Kept `core/1.43` branch's existing structure: container image
`0.0.16`, cloud build steps, `cloud` browser matrix entry, `Get PR
Number` step (not `resolve-pr-from-workflow-run`),
`pnpm/action-setup@v4.4.0`.
- Applied only the PR's intended behavioral changes on top.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11592-backport-core-1-43-ci-filter-e2e-workflow-on-PRs-to-skip-unrelated-changes-34c6d73d36508158ac90c13b298132fb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 01:09:57 -07:00
Comfy Org PR Bot
ed2df9b280 [backport core/1.43] fix: move template distribution filter from v-show to data pipeline (#11557)
Backport of #11418 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11557-backport-core-1-43-fix-move-template-distribution-filter-from-v-show-to-data-pipeline-34b6d73d365081d2adafc201a82d5e11)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-22 22:46:17 -07:00
Comfy Org PR Bot
608b151a4b [backport core/1.43] fix: include actual slot index in InputSlot/OutputSlot keys to prevent stale indices after autogrow (#11554)
Backport of #11423 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11554-backport-core-1-43-fix-include-actual-slot-index-in-InputSlot-OutputSlot-keys-to-prev-34b6d73d36508152ae27e862d78314ec)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-22 22:45:56 -07:00
Comfy Org PR Bot
b348a53dd5 [backport core/1.43] Fix nodeReplacement not triggering onRemoved (#11512)
Backport of #11509 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11512-backport-core-1-43-Fix-nodeReplacement-not-triggering-onRemoved-3496d73d365081cc9d99c3b69fa9eddd)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-04-21 10:16:31 -07:00
AustinMroz
9a95dd4aa5 [backport core/1.43] Fix dropdown chevron color (#11482)
Manual backport of #11335 to `core/1.43`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11482-backport-core-1-43-Fix-dropdown-chevron-color-3486d73d3650810c9722d2f6b8c14c6b)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2026-04-21 04:39:29 +00:00
Comfy Org PR Bot
d37f77cf94 [backport core/1.43] fix: deploy website previews via GitHub Actions instead of Vercel auto-deploy (#11483)
Backport of #11289 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11483-backport-core-1-43-fix-deploy-website-previews-via-GitHub-Actions-instead-of-Vercel-a-3486d73d36508164a683c20663d017f3)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-20 21:31:47 -07:00
Comfy Org PR Bot
cf2cf7f888 [backport core/1.43] fix: show credits in legacy user popover on non-cloud distributions (#11494)
Backport of #11463 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11494-backport-core-1-43-fix-show-credits-in-legacy-user-popover-on-non-cloud-distributions-3496d73d365081f8b75edff32044a7df)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-20 19:19:58 -07:00
Comfy Org PR Bot
a53f352ff0 [backport core/1.43] test: migrate 132 test files from @vue/test-utils to @testing-library/vue (#11488)
Backport of #10965 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11488-backport-core-1-43-test-migrate-132-test-files-from-vue-test-utils-to-testing-libra-3496d73d365081d4bfc2d8db3cb27317)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-20 18:57:39 -07:00
Comfy Org PR Bot
3ea6c83391 [backport core/1.43] fix: missing-asset correctness follow-ups from #10856 (#11466)
Backport of #11233 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11466-backport-core-1-43-fix-missing-asset-correctness-follow-ups-from-10856-3486d73d365081bd94f4e9b8822a8320)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-04-20 11:50:26 +00:00
jaeone94
b2a1108340 [backport core/1.43] fix: exclude muted/bypassed nodes from missing asset detection (#10856) (#11217)
Manual backport of #10856 to `core/1.43`.

## Conflicts resolved

Two files had trivial conflicts from the same root cause — #10856
extracted the inline `cleanup_fake_model` block into a shared
`cleanupFakeModel` helper in
`browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts`:

- `browser_tests/tests/errorOverlay.spec.ts`
- `browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`

`core/1.43` still carries the original inline
`expect(cleanupOk).toBeTruthy()` form. main's version (post-#10967) uses
an inline `expect.poll()` instead. #10856 replaces both with `await
cleanupFakeModel(comfyPage)` calling the helper added by this same PR.

Resolution: accepted the PR version (helper call) on both conflict
sites. The helper itself is added as part of this backport, so no
runtime behavior is lost.

## Verification

- No residual conflict markers
- Cherry-picked commit carries the entire #10856 squash (45 files,
+3596/-209)

## Original PR summary

See #10856 for full behavioral description, test plan, and screenshots.

---
Fixes Comfy-Org/ComfyUI#13256 on core/1.43

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11217-backport-core-1-43-fix-exclude-muted-bypassed-nodes-from-missing-asset-detection-10-3426d73d365081f0becbcf7d909f0021)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-19 18:56:27 -07:00
Comfy Org PR Bot
dae5301f4d [backport core/1.43] fix: persist middle-click reroute node setting across reloads (#11370)
Backport of #11362 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11370-backport-core-1-43-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081969db9f319813665bc)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 18:52:27 -07:00
Comfy Org PR Bot
0ce0c679ef [backport core/1.43] fix: remove appendTo override from FormDropdown to restore correct positioning (#11221)
Backport of #11147 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11221-backport-core-1-43-fix-remove-appendTo-override-from-FormDropdown-to-restore-correct--3426d73d365081df836ce966667433bd)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-14 03:11:40 +00:00
Comfy Org PR Bot
50ddd904e7 [backport core/1.43] fix: check server feature flags for progress_text binary format (#11190)
Backport of #10996 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11190-backport-core-1-43-fix-check-server-feature-flags-for-progress_text-binary-format-3416d73d3650817b9cd3d3c0b26b2f5d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 11:51:59 -07:00
Comfy Org PR Bot
e6a59dcdc2 [backport core/1.43] fix: prevent node context menu from overflowing viewport on desktop (#11122)
Backport of #10854 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11122-backport-core-1-43-fix-prevent-node-context-menu-from-overflowing-viewport-on-desktop-33e6d73d365081898904f66316bab150)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:59 -07:00
Comfy Org PR Bot
924da682ab [backport core/1.43] fix: preserve CustomCombo options through clone and paste (#11124)
Backport of #10853 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11124-backport-core-1-43-fix-preserve-CustomCombo-options-through-clone-and-paste-33e6d73d3650812a9e1ad173cdfb03de)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:49 -07:00
Comfy Org PR Bot
0722a39ba3 [backport core/1.43] fix: debounce reconnecting toast to prevent false-positive banner (#11162)
Backport of #10997 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11162-backport-core-1-43-fix-debounce-reconnecting-toast-to-prevent-false-positive-banner-33f6d73d3650819c8c9bcdc4df8d3e6e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 18:11:18 -07:00
Comfy Org PR Bot
ae0cf28fc3 [backport core/1.43] fix: use standard size-4 for blueprint action icons (#11157)
Backport of #10992 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11157-backport-core-1-43-fix-use-standard-size-4-for-blueprint-action-icons-33f6d73d36508151bf9fffc00eb02150)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-10 18:04:58 -07:00
Comfy Org PR Bot
addd369d85 [backport core/1.43] fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#11120)
Backport of #10973 to `core/1.43`

Automatically created by backport workflow.

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

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 18:22:06 +00:00
Comfy Org PR Bot
8e08877530 [backport core/1.43] fix: use || instead of ?? and server type in WebcamCapture upload path (#11004)
Backport of #11000 to `core/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11004-backport-core-1-43-fix-use-instead-of-and-server-type-in-WebcamCapture-upload-p-33d6d73d3650819980dcfa26a5a378d9)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-09 23:17:44 +00:00
112 changed files with 1702 additions and 5756 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -66,7 +66,6 @@ jobs:
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true

View File

@@ -14,26 +14,36 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: read
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
steps:
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
should_run:
- '!(**.md)'
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
setup:
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true'
if: ${{ needs.changes.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -173,7 +183,7 @@ jobs:
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.should_run == 'true') }}
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -202,6 +212,21 @@ jobs:
path: ./playwright-report/
retention-days: 30
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
@@ -210,7 +235,12 @@ jobs:
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
if: >-
${{
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
steps:
@@ -231,7 +261,13 @@ jobs:
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.should_run == 'true'
if: >-
${{
always() &&
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
contents: read
@@ -256,24 +292,4 @@ jobs:
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
e2e-status:
if: always()
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Determine e2e outcome
run: |
if [[ "${{ needs.changes.outputs.should_run }}" != "true" && "${{ github.event_name }}" == "pull_request" ]]; then
echo "E2E tests skipped (no relevant changes)"
exit 0
fi
if [[ "${{ needs.playwright-tests-chromium-sharded.result }}" == "success" && "${{ needs.playwright-tests.result }}" == "success" ]]; then
echo "All E2E tests passed"
exit 0
fi
echo "E2E tests failed or were cancelled"
echo " chromium-sharded: ${{ needs.playwright-tests-chromium-sharded.result }}"
echo " playwright-tests: ${{ needs.playwright-tests.result }}"
exit 1
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-700);
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-600);
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-500);
background-color: var(--color-coral-red-400);
}
.task-div .p-card {

View File

@@ -1,284 +0,0 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [210, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [210, 168],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [210, 168],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

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

View File

@@ -154,17 +154,12 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
}
export class WorkflowsSidebarTab extends SidebarTab {
public readonly root: Locator
public readonly activeWorkflowLabel: Locator
public readonly searchInput: Locator
constructor(public override readonly page: Page) {
super(page, 'workflows')
this.root = page.getByTestId(TestIds.sidebar.workflows)
this.activeWorkflowLabel = this.root.locator(
'.comfyui-workflows-open .p-tree-node-selected .node-label'
)
this.searchInput = this.root.getByRole('combobox').first()
}
get root() {
return this.page.getByTestId(TestIds.sidebar.workflows)
}
async getOpenedWorkflowNames() {

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -62,30 +62,4 @@ test.describe('Focus Mode', { tag: '@ui' }, () => {
await comfyPage.setFocusMode(true)
await expect(comfyPage.menu.sideToolbar).not.toBeVisible()
})
test('Focus mode toggle preserves properties panel width', async ({
comfyPage
}) => {
// Open the properties panel
await comfyPage.actionbar.propertiesButton.click()
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
// Record the initial panel width
const initialBox = await comfyPage.menu.propertiesPanel.root.boundingBox()
expect(initialBox).not.toBeNull()
const initialWidth = initialBox!.width
// Toggle focus mode on then off
await comfyPage.setFocusMode(true)
await comfyPage.setFocusMode(false)
// Properties panel should be visible again with the same width
await expect(comfyPage.menu.propertiesPanel.root).toBeVisible()
await expect
.poll(async () => {
const box = await comfyPage.menu.propertiesPanel.root.boundingBox()
return box ? Math.abs(box.width - initialWidth) : Infinity
})
.toBeLessThan(2)
})
})

View File

@@ -95,33 +95,3 @@ test.describe('Load3D', () => {
}
)
})
test.describe('Load3D initialization failure', () => {
test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({
comfyPage
}) => {
// Force `new THREE.WebGLRenderer(...)` inside Load3d to throw by making
// WebGL getContext() calls return null.
await comfyPage.page.evaluate(() => {
const proto = HTMLCanvasElement.prototype as {
getContext: (
this: HTMLCanvasElement,
type: string,
options?: unknown
) => unknown
}
const original = proto.getContext
proto.getContext = function (type, options) {
if (type === 'webgl' || type === 'webgl2') return null
return original.call(this, type, options)
}
})
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
await expect(
comfyPage.toast.visibleToasts.filter({
hasText: 'Failed to initialize 3D Viewer'
})
).not.toHaveCount(0)
})
})

View File

@@ -0,0 +1,230 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
}
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {
test.describe(`Ghost node placement (${mode} mode)`, () => {
test.beforeEach(async ({ comfyPage }) => {
await setVueMode(comfyPage, mode === 'vue')
})
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const result = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const cursorCanvasX =
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
const cursorCanvasY =
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
return {
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
diffY: node.pos[1] - 10 - cursorCanvasY
}
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
})
test('left-click confirms ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY)
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).not.toBeNull()
expect(after!.ghost).toBe(false)
})
test('Escape cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Delete cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('right-click cancels ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// `subgraph.getActiveGraphId()` is not on core/1.43; inline via app state
const getActiveGraphId = () =>
comfyPage.page.evaluate(() => window.app!.canvas?.graph?.id ?? null)
const subgraphId = await getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
// `searchBoxV2.open()` is not on core/1.43; the canvas double-click
// opens the v2 search box when Comfy.NodeSearchBoxImpl is set to default
const { searchBoxV2 } = comfyPage
await comfyPage.canvas.dblclick({ position: { x: 400, y: 300 } })
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.page.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect.poll(() => getActiveGraphId()).toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -17,13 +17,14 @@ test.describe('Workflow sidebar - search', () => {
'alpha-workflow.json': 'default.json',
'beta-workflow.json': 'default.json'
})
await comfyPage.menu.workflowsTab.open()
})
test('Search filters saved workflows by name', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await tab.searchInput.fill('alpha')
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await expect(
@@ -33,11 +34,15 @@ test.describe('Workflow sidebar - search', () => {
test('Clearing search restores all workflows', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await tab.searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeHidden()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await tab.searchInput.fill('')
await searchInput.fill('')
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible()
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
@@ -45,8 +50,10 @@ test.describe('Workflow sidebar - search', () => {
test('Search with no matches shows empty results', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await tab.searchInput.fill('nonexistent_xyz')
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('nonexistent_xyz')
await expect(
findWorkflow(comfyPage.page, 'alpha-workflow')
@@ -55,72 +62,4 @@ test.describe('Workflow sidebar - search', () => {
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
})
test.describe('deletion', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({
'alpha-workflow.json': 'default.json',
'beta-workflow.json': 'default.json',
'gamma-workflow.json': 'default.json'
})
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
await comfyPage.menu.workflowsTab.open()
})
test('Deleting a workflow while search is active removes it from results', async ({
comfyPage
}) => {
const tab = comfyPage.menu.workflowsTab
await tab.searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
button: 'right'
})
await comfyPage.contextMenu.clickMenuItem('Delete')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
})
test('Deleting during search does not affect other matched results', async ({
comfyPage
}) => {
const tab = comfyPage.menu.workflowsTab
await tab.searchInput.fill('workflow')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
button: 'right'
})
await comfyPage.contextMenu.clickMenuItem('Delete')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeHidden()
await expect(findWorkflow(comfyPage.page, 'beta-workflow')).toBeVisible()
await expect(findWorkflow(comfyPage.page, 'gamma-workflow')).toBeVisible()
})
test('Clearing search after deleting during search shows correct workflows', async ({
comfyPage
}) => {
const tab = comfyPage.menu.workflowsTab
await tab.searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible()
await findWorkflow(comfyPage.page, 'alpha-workflow').click({
button: 'right'
})
await comfyPage.contextMenu.clickMenuItem('Delete')
await tab.searchInput.fill('')
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
await expect(tab.getPersistedItem('gamma-workflow')).toBeVisible()
await expect(tab.getPersistedItem('alpha-workflow')).toBeHidden()
})
})
})

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
@@ -9,31 +8,6 @@ const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -109,35 +83,5 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.16",
"version": "1.43.17",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -81,7 +81,6 @@
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
@@ -113,7 +112,6 @@
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

View File

@@ -73,6 +73,10 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;

36
pnpm-lock.yaml generated
View File

@@ -159,9 +159,6 @@ catalogs:
'@types/three':
specifier: ^0.169.0
version: 0.169.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1
@@ -354,9 +351,6 @@ catalogs:
unplugin-vue-components:
specifier: ^30.0.0
version: 30.0.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4
@@ -494,9 +488,6 @@ importers:
'@tiptap/starter-kit':
specifier: 'catalog:'
version: 2.27.2
'@vee-validate/zod':
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)
'@vueuse/core':
specifier: 'catalog:'
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -590,9 +581,6 @@ importers:
typegpu:
specifier: 'catalog:'
version: 0.8.2
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -4652,11 +4640,6 @@ packages:
peerDependencies:
valibot: ^1.2.0
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
@@ -9444,11 +9427,6 @@ packages:
typescript:
optional: true
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -13732,14 +13710,6 @@ snapshots:
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))':
optionalDependencies:
react: 19.2.4
@@ -19644,12 +19614,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.13(typescript@5.9.3)
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3

View File

@@ -54,7 +54,6 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
@@ -119,7 +118,6 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vee-validate: ^4.15.1
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2

View File

@@ -1,233 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
const TARGET = 80
const MILESTONE_STEP = 5
const MIN_DELTA = 0.05
const BAR_WIDTH = 20
interface CoverageData {
percentage: number
totalLines: number
coveredLines: number
}
interface SlackBlock {
type: 'section'
text: {
type: 'mrkdwn'
text: string
}
}
function parseLcovContent(content: string): CoverageData | null {
const perFile = new Map<string, { lf: number; lh: number }>()
let currentFile = ''
for (const line of content.split('\n')) {
if (line.startsWith('SF:')) {
currentFile = line.slice(3)
} else if (line.startsWith('LF:')) {
const n = parseInt(line.slice(3), 10) || 0
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
entry.lf = n
perFile.set(currentFile, entry)
} else if (line.startsWith('LH:')) {
const n = parseInt(line.slice(3), 10) || 0
const entry = perFile.get(currentFile) ?? { lf: 0, lh: 0 }
entry.lh = n
perFile.set(currentFile, entry)
}
}
let totalLines = 0
let coveredLines = 0
for (const { lf, lh } of perFile.values()) {
totalLines += lf
coveredLines += lh
}
if (totalLines === 0) return null
return {
percentage: (coveredLines / totalLines) * 100,
totalLines,
coveredLines
}
}
function parseLcov(filePath: string): CoverageData | null {
if (!existsSync(filePath)) return null
return parseLcovContent(readFileSync(filePath, 'utf-8'))
}
function progressBar(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage))
const filled = Math.round((clamped / 100) * BAR_WIDTH)
const empty = BAR_WIDTH - filled
return '█'.repeat(filled) + '░'.repeat(empty)
}
function formatPct(value: number): string {
return value.toFixed(1) + '%'
}
function formatDelta(delta: number): string {
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
const sign = rounded >= 0 ? '+' : ''
return sign + rounded.toFixed(1) + '%'
}
function crossedMilestone(prev: number, curr: number): number | null {
const prevBucket = Math.floor(prev / MILESTONE_STEP)
const currBucket = Math.floor(curr / MILESTONE_STEP)
if (currBucket > prevBucket) {
return currBucket * MILESTONE_STEP
}
return null
}
function buildMilestoneBlock(label: string, milestone: number): SlackBlock {
if (milestone >= TARGET) {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: [
`🏆 *GOAL REACHED: ${label} coverage hit ${milestone}%!* 🏆`,
`\`${progressBar(milestone)}\` ${milestone}% ✅`,
'The team did it! 🎊🥳🎉'
].join('\n')
}
}
}
const remaining = TARGET - milestone
return {
type: 'section',
text: {
type: 'mrkdwn',
text: [
`🎉🎉🎉 *MILESTONE: ${label} coverage hit ${milestone}%!*`,
`\`${progressBar(milestone)}\` ${milestone}% → ${TARGET}% target`,
`${remaining} percentage point${remaining !== 1 ? 's' : ''} to go!`
].join('\n')
}
}
}
function parseArgs(argv: string[]): {
prUrl: string
prNumber: string
author: string
} {
let prUrl = ''
let prNumber = ''
let author = ''
for (const arg of argv) {
if (arg.startsWith('--pr-url=')) prUrl = arg.slice('--pr-url='.length)
else if (arg.startsWith('--pr-number='))
prNumber = arg.slice('--pr-number='.length)
else if (arg.startsWith('--author=')) author = arg.slice('--author='.length)
}
return { prUrl, prNumber, author }
}
function formatCoverageRow(
label: string,
current: CoverageData,
baseline: CoverageData
): string {
const delta = current.percentage - baseline.percentage
return `*${label}:* ${formatPct(baseline.percentage)}${formatPct(current.percentage)} (${formatDelta(delta)})`
}
function main() {
const { prUrl, prNumber, author } = parseArgs(process.argv.slice(2))
const unitCurrent = parseLcov('coverage/lcov.info')
const unitBaseline = parseLcov('temp/coverage-baseline/lcov.info')
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
const unitDelta =
unitCurrent !== null && unitBaseline !== null
? unitCurrent.percentage - unitBaseline.percentage
: 0
const e2eDelta =
e2eCurrent !== null && e2eBaseline !== null
? e2eCurrent.percentage - e2eBaseline.percentage
: 0
const unitImproved = unitDelta >= MIN_DELTA
const e2eImproved = e2eDelta >= MIN_DELTA
if (!unitImproved && !e2eImproved) {
process.exit(0)
}
const blocks: SlackBlock[] = []
const summaryLines: string[] = []
summaryLines.push(
`✅ *Coverage improved!* — <${prUrl}|PR #${prNumber}> by <https://github.com/${author}|${author}>`
)
summaryLines.push('')
if (unitImproved) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
}
if (e2eImproved) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
}
summaryLines.push('')
if (unitCurrent) {
summaryLines.push(
`\`${progressBar(unitCurrent.percentage)}\` ${formatPct(unitCurrent.percentage)} unit → ${TARGET}% target`
)
}
if (e2eCurrent) {
summaryLines.push(
`\`${progressBar(e2eCurrent.percentage)}\` ${formatPct(e2eCurrent.percentage)} e2e → ${TARGET}% target`
)
}
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: summaryLines.join('\n')
}
})
if (unitCurrent && unitBaseline) {
const milestone = crossedMilestone(
unitBaseline.percentage,
unitCurrent.percentage
)
if (milestone !== null) {
blocks.push(buildMilestoneBlock('Unit test', milestone))
}
}
if (e2eCurrent && e2eBaseline) {
const milestone = crossedMilestone(
e2eBaseline.percentage,
e2eCurrent.percentage
)
if (milestone !== null) {
blocks.push(buildMilestoneBlock('E2E test', milestone))
}
}
const payload = { text: 'Coverage improved!', blocks }
process.stdout.write(JSON.stringify(payload))
}
main()

View File

@@ -171,10 +171,14 @@ const sidebarPanelVisible = computed(
)
const firstPanelVisible = computed(
() => sidebarLocation.value === 'left' || showOffsideSplitter.value
() =>
!focusMode.value &&
(sidebarLocation.value === 'left' || showOffsideSplitter.value)
)
const lastPanelVisible = computed(
() => sidebarLocation.value === 'right' || showOffsideSplitter.value
() =>
!focusMode.value &&
(sidebarLocation.value === 'right' || showOffsideSplitter.value)
)
/**
@@ -264,7 +268,6 @@ const splitterRefreshKey = computed(() => {
})
const firstPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -272,7 +275,6 @@ const firstPanelStyle = computed(() => {
})
const lastPanelStyle = computed(() => {
if (focusMode.value) return { display: 'none' }
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
@@ -291,13 +293,9 @@ const lastPanelStyle = computed(() => {
background-color: var(--p-primary-color);
}
/* Hide gutter when adjacent panel is not visible */
:deep(
[data-pc-name='splitterpanel'][style*='display: none'] + .p-splitter-gutter
),
:deep(
.p-splitter-gutter + [data-pc-name='splitterpanel'][style*='display: none']
) {
/* Hide sidebar gutter when sidebar is not visible */
:deep(.side-bar-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .side-bar-panel[style*='display: none']) {
display: none;
}

View File

@@ -20,12 +20,7 @@
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag
v-if="item.isBlueprint"
data-testid="subgraph-breadcrumb-blueprint-tag"
:value="t('breadcrumbsMenu.blueprint')"
severity="primary"
/>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
</div>
<Menu

View File

@@ -1,110 +0,0 @@
import { Form, FormField } from '@primevue/forms'
import { render, screen } from '@testing-library/vue'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignUpForm from './SignUpForm.vue'
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoadingRef.value
}
}))
}))
describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(SignUpForm, {
global: {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
}
})
}
describe('Password manager autofill attributes', () => {
it('renders email input with attributes Chrome needs to recognize the field', () => {
renderComponent()
const emailInput = screen.getByPlaceholderText(
enMessages.auth.signup.emailPlaceholder
)
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email')
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('type', 'email')
})
it('renders password input with new-password autofill attributes', () => {
renderComponent()
const passwordInput = screen.getByPlaceholderText(
enMessages.auth.signup.passwordPlaceholder
)
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password')
expect(passwordInput).toHaveAttribute('name', 'password')
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password')
})
it('renders confirm-password input with distinct name and new-password autocomplete', () => {
renderComponent()
const confirmPasswordInput = screen.getByPlaceholderText(
enMessages.auth.login.confirmPasswordPlaceholder
)
expect(confirmPasswordInput).toHaveAttribute(
'id',
'comfy-org-sign-up-confirm-password'
)
expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword')
expect(confirmPasswordInput).toHaveAttribute(
'autocomplete',
'new-password'
)
})
})
})

View File

@@ -15,10 +15,9 @@
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:name="email"
pt:root:autocomplete="email"
class="h-10"
type="email"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$field.invalid"
/>

View File

@@ -8,6 +8,11 @@
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
>
<!-- Native drag area for Electron -->
<div
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full"
/>
<div
class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface"
>
@@ -184,6 +189,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'

View File

@@ -299,16 +299,14 @@ function handleTitleCancel() {
@cancel="handleTitleCancel"
@click="isEditing = true"
/>
<Button
<button
v-if="!isEditing"
variant="link"
size="unset"
:aria-label="t('rightSidePanel.editTitle')"
class="relative top-[2px] ml-2 shrink-0"
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
@click="isEditing = true"
>
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
</Button>
</button>
</template>
<template v-else>
{{ panelTitle }}

View File

@@ -109,10 +109,8 @@ const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(
() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')

View File

@@ -1,305 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { nextTick, reactive, ref, watchEffect } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { flattenTree } from '@/utils/treeUtil'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
const {
setSearchQuery,
emitSearch,
captureSearchRoot,
getSearchRoot,
resetCapturedSearchRoot,
mockExpandNode,
mockToggleNodeOnEvent,
mockLoadBookmarks,
mockWorkflowService,
mockWorkflowStoreState,
registerSearchHandlers
} = vi.hoisted(() => {
let updateQuery = (_query: string) => {}
let triggerSearch = (_query: string) => {}
let capturedSearchRoot: TreeExplorerNode<ComfyWorkflow> | null = null
const workflowStore = {
workflows: [] as ComfyWorkflow[],
persistedWorkflows: [] as ComfyWorkflow[],
bookmarkedWorkflows: [] as ComfyWorkflow[],
openWorkflows: [] as ComfyWorkflow[],
activeWorkflow: null as ComfyWorkflow | null,
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
return {
setSearchQuery: (query: string) => {
updateQuery(query)
},
emitSearch: (query: string) => {
triggerSearch(query)
},
captureSearchRoot: (root: TreeExplorerNode<ComfyWorkflow>) => {
capturedSearchRoot = root
},
getSearchRoot: () => capturedSearchRoot,
resetCapturedSearchRoot: () => {
capturedSearchRoot = null
},
mockExpandNode: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockLoadBookmarks: vi.fn().mockResolvedValue(undefined),
mockWorkflowService: {
openWorkflow: vi.fn().mockResolvedValue(undefined),
closeWorkflow: vi.fn().mockResolvedValue(undefined),
renameWorkflow: vi.fn().mockResolvedValue(undefined),
deleteWorkflow: vi.fn().mockResolvedValue(undefined),
insertWorkflow: vi.fn().mockResolvedValue(undefined),
duplicateWorkflow: vi.fn().mockResolvedValue(undefined)
},
mockWorkflowStoreState: workflowStore,
registerSearchHandlers: (
updateHandler: (query: string) => void,
searchHandler: (query: string) => void
) => {
updateQuery = updateHandler
triggerSearch = searchHandler
}
}
})
const mockWorkflowStore = reactive(mockWorkflowStoreState)
vi.mock('@/components/common/NoResultsPlaceholder.vue', () => ({
default: { name: 'NoResultsPlaceholder', template: '<div />' }
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchInput',
template: '<div data-testid="search-input" />',
props: ['modelValue', 'placeholder'],
setup(
_props: { modelValue: string; placeholder?: string },
{
emit,
expose
}: {
emit: (event: 'update:modelValue' | 'search', value: string) => void
expose: (value: { focus: () => void }) => void
}
) {
const focus = vi.fn()
expose({ focus })
registerSearchHandlers(
(query: string) => emit('update:modelValue', query),
(query: string) => emit('search', query)
)
return {}
}
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTopArea.vue', () => ({
default: { name: 'SidebarTopArea', template: '<div><slot /></div>' }
}))
vi.mock('@/components/common/TextDivider.vue', () => ({
default: { name: 'TextDivider', template: '<div />' }
}))
vi.mock('@/components/common/TreeExplorer.vue', () => ({
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'selectionKeys', 'expandedKeys'],
setup(props: {
root: TreeExplorerNode<ComfyWorkflow>
selectionKeys?: Record<string, boolean>
}) {
watchEffect(() => {
if (props.selectionKeys === undefined) {
captureSearchRoot(props.root)
}
})
}
}
}))
vi.mock('@/components/common/TreeExplorerTreeNode.vue', () => ({
default: {
name: 'TreeExplorerTreeNode',
template:
'<div><slot name="before-label" :node="node" /><slot /><slot name="actions" :node="node" /></div>',
props: ['node']
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
}
}))
vi.mock('@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue', () => ({
default: { name: 'WorkflowTreeLeaf', template: '<div />', props: ['node'] }
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: { name: 'Button', template: '<button><slot /></button>' }
}))
vi.mock('@/composables/useTreeExpansion', () => ({
useTreeExpansion: () => ({
expandNode: mockExpandNode,
toggleNodeOnEvent: mockToggleNodeOnEvent
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ isAppMode: ref(false) })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.WorkflowTabsPosition') return 'Sidebar'
return undefined
})
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => mockWorkflowService
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore,
useWorkflowBookmarkStore: () => ({ loadBookmarks: mockLoadBookmarks }),
ComfyWorkflow: class {
static basePath = 'workflows/'
}
}))
vi.mock('primevue/confirmdialog', () => ({
default: { name: 'ConfirmDialog', template: '<div />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const createMockWorkflow = (path: string) =>
fromPartial<ComfyWorkflow>({
path,
key: path.replace('workflows/', ''),
isModified: false,
isPersisted: true,
isTemporary: false,
suffix: 'json',
directory: 'workflows'
})
const getLeafPaths = (
root: TreeExplorerNode<ComfyWorkflow> | null
): string[] => {
if (!root) return []
return flattenTree<ComfyWorkflow>(root)
.map((w) => w.path)
.sort()
}
describe('BaseWorkflowsSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
resetCapturedSearchRoot()
mockWorkflowStore.workflows = []
mockWorkflowStore.persistedWorkflows = []
mockWorkflowStore.bookmarkedWorkflows = []
mockWorkflowStore.openWorkflows = []
mockWorkflowStore.activeWorkflow = null
})
const renderComponent = () =>
render(BaseWorkflowsSidebarTab, {
props: {
title: 'Workflows',
searchSubject: 'Workflow',
dataTestid: 'workflows-sidebar'
},
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
stubs: { teleport: true }
}
})
it('returns an empty filtered workflow set when searchQuery is empty', async () => {
mockWorkflowStore.workflows = [
createMockWorkflow('workflows/test-alpha.json'),
createMockWorkflow('workflows/test-beta.json')
]
renderComponent()
emitSearch('alpha')
await nextTick()
expect(mockExpandNode).toHaveBeenCalledTimes(1)
const expandedRoot = mockExpandNode.mock.calls[0]?.[0] as
| TreeExplorerNode<ComfyWorkflow>
| undefined
expect(getLeafPaths(expandedRoot ?? null)).toHaveLength(0)
})
it('filters workflows by case-insensitive path match', async () => {
mockWorkflowStore.workflows = [
createMockWorkflow('workflows/test-alpha.json'),
createMockWorkflow('workflows/other-workflow.json'),
createMockWorkflow('workflows/TEST-gamma.json')
]
renderComponent()
setSearchQuery('ALPHA')
await nextTick()
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
})
it('reactively updates filtered workflows when a workflow is removed', async () => {
mockWorkflowStore.workflows = [
createMockWorkflow('workflows/test-alpha.json'),
createMockWorkflow('workflows/TEST-alpha-2.json'),
createMockWorkflow('workflows/test-beta.json')
]
renderComponent()
setSearchQuery('alpha')
await nextTick()
expect(getLeafPaths(getSearchRoot())).toEqual([
'workflows/TEST-alpha-2.json',
'workflows/test-alpha.json'
])
mockWorkflowStore.workflows = mockWorkflowStore.workflows.filter(
(workflow) => workflow.path !== 'workflows/TEST-alpha-2.json'
)
await nextTick()
expect(getLeafPaths(getSearchRoot())).toEqual(['workflows/test-alpha.json'])
})
})

View File

@@ -194,21 +194,22 @@ const searchBoxRef = ref()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.length > 0)
const filteredWorkflows = computed(() => {
if (searchQuery.value.length === 0) return []
const lowerQuery = searchQuery.value.toLocaleLowerCase()
return applyFilter(workflowStore.workflows).filter((workflow) =>
workflow.path.toLocaleLowerCase().includes(lowerQuery)
)
})
const filteredWorkflows = ref<ComfyWorkflow[]>([])
const filteredRoot = computed<TreeNode>(() => {
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
})
const handleSearch = async (query: string) => {
if (query.length === 0) {
filteredWorkflows.value = []
expandedKeys.value = {}
return
}
const lowerQuery = query.toLocaleLowerCase()
filteredWorkflows.value = applyFilter(workflowStore.workflows).filter(
(workflow) => {
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
}
)
await nextTick()
expandNode(filteredRoot.value)
}

View File

@@ -1,51 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
progressToast: {
finished: 'Finished',
failed: 'Failed',
pending: 'Pending'
}
}
}
})
function completedJob(): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'controlnet-canny.safetensors',
bytesTotal: 100,
bytesDownloaded: 100,
progress: 1,
status: 'completed',
lastUpdate: Date.now()
}
}
describe('ProgressToastItem — completed state', () => {
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
render(ProgressToastItem, {
props: { job: completedJob() },
global: { plugins: [i18n] }
})
const badge = screen.getByText('Finished')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(badge.closest('.opacity-50')).toBeNull()
const assetName = screen.getByText('controlnet-canny.safetensors')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(assetName.closest('.opacity-50')).not.toBeNull()
})
})

View File

@@ -22,9 +22,14 @@ const isPending = computed(() => job.status === 'created')
<template>
<div
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
<div class="min-w-0 flex-1">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchAutocomplete from './SearchAutocomplete.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { searchPlaceholder: 'Search...' } } }
})
describe('SearchAutocomplete', () => {
function renderComponent(props: Record<string, unknown> = {}) {
return render(SearchAutocomplete, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: { template: '<div><slot /></div>' },
ComboboxAnchor: { template: '<div><slot /></div>' },
ComboboxInput: {
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue'],
emits: ['update:modelValue']
},
ComboboxPortal: { template: '<div><slot /></div>' },
ComboboxContent: { template: '<div><slot /></div>' },
ComboboxItem: {
template:
'<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>',
emits: ['select']
}
}
},
props: { modelValue: '', ...props }
})
}
describe('suggestions dropdown', () => {
it('does not render items when suggestions list is empty', () => {
renderComponent({ suggestions: [] })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('renders a button for each suggestion', () => {
renderComponent({ suggestions: ['foo', 'bar'] })
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('emits select with the suggestion when an item is clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
renderComponent({ suggestions: ['foo', 'bar'], onSelect })
await user.click(screen.getByText('foo'))
expect(onSelect).toHaveBeenCalledWith('foo')
})
it('updates modelValue to the suggestion label on selection', async () => {
const onUpdateModelValue = vi.fn()
const user = userEvent.setup()
renderComponent({
suggestions: ['foo', 'bar'],
'onUpdate:modelValue': onUpdateModelValue
})
await user.click(screen.getByText('foo'))
expect(onUpdateModelValue).toHaveBeenCalledWith('foo')
})
})
describe('with optionLabel', () => {
it('displays the optionLabel property as the suggestion text', () => {
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query' })
expect(screen.getByText('my-extension')).toBeInTheDocument()
})
it('emits the full item object on selection when optionLabel is set', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
const suggestions = [{ id: 1, query: 'my-extension' }]
renderComponent({ suggestions, optionLabel: 'query', onSelect })
await user.click(screen.getByText('my-extension'))
expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' })
})
})
})

View File

@@ -65,36 +65,34 @@
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxPortal>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</template>
@@ -107,7 +105,6 @@ import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'

View File

@@ -35,7 +35,7 @@
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
>
<div class="flex min-w-0 flex-1 gap-2">
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile && !showLeftPanel"
size="lg"

View File

@@ -117,7 +117,7 @@ export const useAuthActions = () => {
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier, openInNewTab?: boolean],
boolean
void
>(async (targetTier, openInNewTab = true) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
@@ -128,11 +128,10 @@ export const useAuthActions = () => {
)
}
if (openInNewTab) {
return window.open(response.billing_portal_url, '_blank') !== null
window.open(response.billing_portal_url, '_blank')
} else {
globalThis.location.href = response.billing_portal_url
}
globalThis.location.href = response.billing_portal_url
return true
}, reportError)
const fetchBalance = wrapWithErrorHandlingAsync(async () => {

View File

@@ -20,7 +20,27 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
function seedSimpleError(
store: ReturnType<typeof useExecutionErrorStore>,
executionId: string,
inputName: string
) {
store.lastNodeErrors = {
[executionId]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: inputName }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
}
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
@@ -43,7 +63,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -55,7 +75,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -74,7 +94,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -96,7 +116,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
seedSimpleError(store, String(node.id), 'model')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -241,11 +261,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedRequiredInputMissingNodeError(
store,
interiorExecId,
promotedWidget!.name
)
seedSimpleError(store, interiorExecId, promotedWidget!.name)
subgraphNode.onWidgetChanged!.call(
subgraphNode,
@@ -284,7 +300,7 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
seedSimpleError(store, String(lateNode.id), 'value')
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -86,129 +86,6 @@ describe('useNodeDragAndDrop', () => {
expect(isDragging).toBe(false)
})
describe('claimEvent flag', () => {
function createClaimableEvent(
options: Parameters<typeof createDragEvent>[0]
) {
const event = createDragEvent(options)
const preventDefault = vi.fn()
const stopPropagation = vi.fn()
Object.assign(event, { preventDefault, stopPropagation })
return { event, preventDefault, stopPropagation }
}
it('claims the event synchronously before awaiting onDrop for valid file drops', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const onDrop = vi.fn().mockImplementation(async () => {
// By the time onDrop runs, the event must already be claimed —
// claiming after the await would let document fallback handlers fire.
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return []
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
expect(onDrop).toHaveBeenCalledTimes(1)
})
it('does not claim the event when files are filtered out', async () => {
const node = createNode()
useNodeDragAndDrop(node, {
onDrop: vi.fn().mockResolvedValue([]),
fileFilter: (file) => file.type === 'image/png'
})
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.jpg', 'image/jpeg')]
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('claims the event for same-origin uri drops before fetching', async () => {
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: `${location.origin}/api/file?filename=uri.png`,
types: ['text/uri-list']
})
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(stopPropagation).toHaveBeenCalledTimes(1)
return fromAny<Response, unknown>({
ok: true,
blob: vi
.fn()
.mockResolvedValue(new Blob(['uri'], { type: 'image/png' }))
})
})
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(true)
})
it('does not claim the event for cross-origin uri drops', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
uri: 'https://example.com/api/file?filename=uri.png',
types: ['text/uri-list']
})
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when drop has no files and no uri', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent(
{}
)
const result = await node.onDragDrop?.(event, true)
expect(result).toBe(false)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
it('does not claim the event when claimEvent is omitted', async () => {
const node = createNode()
useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) })
const { event, preventDefault, stopPropagation } = createClaimableEvent({
files: [createFile('a.png')]
})
const result = await node.onDragDrop?.(event)
expect(result).toBe(true)
expect(preventDefault).not.toHaveBeenCalled()
expect(stopPropagation).not.toHaveBeenCalled()
})
})
it('onDragDrop calls onDrop with filtered files', async () => {
const onDrop = vi.fn().mockResolvedValue([])
const node = createNode()

View File

@@ -326,7 +326,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(containerRef)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3dViewer'
'toastMessages.failedToInitializeLoad3d'
)
})
@@ -346,39 +346,6 @@ describe('useLoad3d', () => {
})
})
describe('preserves existing node callbacks through initializeLoad3d', () => {
// Regression: FE-214 — undo triggers rootGraph.clear() which fires
// node.onRemoved on the outgoing node. addWidget() chains a cleanup that
// unregisters the component widget from the DOM widget store. If
// initializeLoad3d overwrites node.onRemoved instead of chaining, that
// cleanup is lost and the interactive UI persists with a stale reference.
it('chains node.onRemoved with a preexisting callback', async () => {
const existingOnRemoved = vi.fn()
mockNode.onRemoved = existingOnRemoved
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onRemoved?.()
expect(existingOnRemoved).toHaveBeenCalledTimes(1)
})
it('chains node.onResize with a preexisting callback', async () => {
const existingOnResize = vi.fn()
mockNode.onResize = existingOnResize
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.onResize?.([512, 512] as Size)
expect(existingOnResize).toHaveBeenCalledTimes(1)
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)

View File

@@ -3,7 +3,6 @@ import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { nextTick, ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
@@ -115,32 +114,30 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
await restoreConfigurationsFromNode(node)
node.onMouseEnter = useChainCallback(node.onMouseEnter, () => {
node.onMouseEnter = function () {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
})
}
node.onMouseLeave = useChainCallback(node.onMouseLeave, () => {
node.onMouseLeave = function () {
load3d?.updateStatusMouseOnNode(false)
})
}
node.onResize = useChainCallback(node.onResize, () => {
node.onResize = function () {
load3d?.handleResize()
})
}
node.onDrawBackground = useChainCallback(
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
)
}
node.onRemoved = useChainCallback(node.onRemoved, () => {
node.onRemoved = function () {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
})
}
nodeToLoad3dMap.set(node, load3d)
@@ -158,9 +155,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(
t('toastMessages.failedToInitializeLoad3dViewer')
)
useToastStore().addAlert(t('toastMessages.failedToInitializeLoad3d'))
}
}

View File

@@ -430,17 +430,6 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
it('should sync hover state when mouseenter fires before init', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
viewer.handleMouseEnter()
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
})
describe('restoreInitialState', () => {
@@ -478,23 +467,6 @@ describe('useLoad3dViewer', () => {
.intensity
).toBe(1)
})
it('should preserve unknown fields on Model Config when restoring', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
;(
mockNode.properties!['Model Config'] as Record<string, unknown>
).futureField = 'preserve-me'
viewer.restoreInitialState()
expect(
(mockNode.properties!['Model Config'] as Record<string, unknown>)
.futureField
).toBe('preserve-me')
})
})
describe('applyChanges', () => {
@@ -548,23 +520,6 @@ describe('useLoad3dViewer', () => {
expect(result).toBe(false)
})
it('should preserve unknown fields on Model Config when applying', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
;(
mockNode.properties!['Model Config'] as Record<string, unknown>
).futureField = 'preserve-me'
await viewer.applyChanges()
expect(
(mockNode.properties!['Model Config'] as Record<string, unknown>)
.futureField
).toBe('preserve-me')
})
})
describe('refreshViewport', () => {

View File

@@ -86,7 +86,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
let mouseOnViewer = false
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -305,10 +304,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: hasTargetDimensions
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraState = source.getCameraState()
@@ -421,10 +416,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: true
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
@@ -531,7 +522,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
mouseOnViewer = true
load3d?.updateStatusMouseOnViewer(true)
}
@@ -539,7 +529,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
mouseOnViewer = false
load3d?.updateStatusMouseOnViewer(false)
}
@@ -570,11 +559,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
intensity: initialState.value.lightIntensity
}
const existingModelConfig = nodeValue.properties['Model Config'] as
| ModelConfig
| undefined
nodeValue.properties['Model Config'] = {
...existingModelConfig,
upDirection: initialState.value.upDirection,
materialMode: initialState.value.materialMode
}
@@ -618,11 +603,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
intensity: lightIntensity.value
}
const existingModelConfig = nodeValue.properties['Model Config'] as
| ModelConfig
| undefined
nodeValue.properties['Model Config'] = {
...existingModelConfig,
upDirection: upDirection.value,
materialMode: materialMode.value
}
@@ -746,7 +727,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
mouseOnViewer = false
load3d?.remove()
load3d = null
sourceLoad3d = null

View File

@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
/** Whether the resolved source widget is workflow-persistent. */
readonly sourceSerialize: boolean
}
export function isPromotedWidgetView(

View File

@@ -1,6 +1,3 @@
import { isEqual } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
@@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
}
const designTokenCache = new Map<string, string>()
const promotedSourceWriteMetaByGraph = new WeakMap<
LGraph,
Map<string, PromotedSourceWriteMeta>
>()
interface PromotedSourceWriteMeta {
value: IBaseWidget['value']
writerInstanceId: string
}
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
value: TValue
): TValue {
return value != null && typeof value === 'object'
? (JSON.parse(JSON.stringify(value)) as TValue)
: value
}
function getPromotedSourceWriteMeta(
graph: LGraph,
sourceKey: string
): PromotedSourceWriteMeta | undefined {
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
}
function setPromotedSourceWriteMeta(
graph: LGraph,
sourceKey: string,
meta: PromotedSourceWriteMeta
): void {
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
if (!metaBySource) {
metaBySource = new Map<string, PromotedSourceWriteMeta>()
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
}
metaBySource.set(sourceKey, meta)
}
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
@@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
readonly serialize = false
/**
* Whether the resolved source widget is workflow-persistent.
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
* whose source sets serialize = false.
*/
get sourceSerialize(): boolean {
return this.resolveDeepest()?.widget.serialize !== false
}
last_y?: number
computedHeight?: number
@@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.resolveDeepest()?.widget.linkedWidgets
}
private get _instanceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.sourceNodeId}:${this.sourceWidgetName}`
}
private get _sharedSourceKey(): string {
return this.disambiguatingSourceNodeId
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
}
get value(): IBaseWidget['value'] {
return this.getTrackedValue()
}
/**
* Execution-time serialization — returns the per-instance value stored
* during configure, falling back to the regular value getter.
*
* The widget state store is shared across instances (keyed by inner node
* ID), so the regular getter returns the last-configured value for all
* instances. graphToPrompt already prefers serializeValue over .value,
* so this is the hook that makes multi-instance execution correct.
*/
serializeValue(): IBaseWidget['value'] {
return this.getTrackedValue()
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.captureSiblingFallbackValues()
// Keep per-instance map in sync for execution (graphToPrompt)
this.subgraphNode._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(value)
)
setPromotedSourceWriteMeta(
this.subgraphNode.rootGraph,
this._sharedSourceKey,
{
value: cloneWidgetValue(value),
writerInstanceId: String(this.subgraphNode.id)
}
)
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
@@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolved
}
private getTrackedValue(): IBaseWidget['value'] {
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
this._instanceKey
)
const sharedValue = this.getSharedValue()
if (instanceValue === undefined) return sharedValue
const sourceWriteMeta = getPromotedSourceWriteMeta(
this.subgraphNode.rootGraph,
this._sharedSourceKey
)
if (
sharedValue !== undefined &&
sourceWriteMeta &&
!isEqual(sharedValue, sourceWriteMeta.value)
) {
this.subgraphNode._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(sharedValue)
)
return sharedValue
}
return instanceValue as IBaseWidget['value']
}
private getSharedValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
@@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
.filter((state): state is WidgetState => state !== undefined)
}
private captureSiblingFallbackValues(): void {
const { rootGraph } = this.subgraphNode
for (const node of rootGraph.nodes) {
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
if (node._instanceWidgetValues.has(this._instanceKey)) continue
const siblingView = node.widgets.find(
(widget): widget is IPromotedWidgetView =>
isPromotedWidgetView(widget) &&
widget.sourceNodeId === this.sourceNodeId &&
widget.sourceWidgetName === this.sourceWidgetName &&
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
)
if (!siblingView) continue
node._instanceWidgetValues.set(
this._instanceKey,
cloneWidgetValue(siblingView.value)
)
}
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget

View File

@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize stores widgets_values for promoted views', () => {
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toEqual(['value'])
// 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', () => {

View File

@@ -1,6 +1,6 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -9,6 +9,9 @@ import type { HasInitialMinSize } from '@/services/litegraphService'
setActivePinia(createTestingPinia())
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
type TestAutogrowNode = LGraphNode & {
comfyDynamic: { autogrow: Record<string, unknown> }
}
const { addNodeInput } = useLitegraphService()
@@ -182,6 +185,45 @@ describe('Autogrow', () => {
await nextTick()
expect(node.inputs.length).toBe(5)
})
test('Removing a connection ignores stale autogrow callbacks after group removal', () => {
const graph = new LGraph()
const node = testNode() as TestAutogrowNode
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
try {
connectInput(node, 0, graph)
expect(node.inputs.length).toBe(2)
rafCallbacks.shift()?.(0)
node.disconnectInput(0)
const staleDisconnectCallback = rafCallbacks.shift()
expect(staleDisconnectCallback).toBeDefined()
delete node.comfyDynamic.autogrow['0']
const callbackCountBeforeFlush = onConnectionsChange.mock.calls.length
staleDisconnectCallback?.(0)
expect(onConnectionsChange).toHaveBeenCalledTimes(
callbackCountBeforeFlush
)
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
test('Multi-group autogrow shifts second group indices on first group growth', () => {
const graph = new LGraph()
const node = testNode()

View File

@@ -464,7 +464,10 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
if (!input) return
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
const autogrowGroup = node.comfyDynamic.autogrow[groupName]
if (!autogrowGroup) return
const { min = 1, inputSpecs } = autogrowGroup
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (ordinal == undefined || ordinal + 1 < min) return

View File

@@ -1,51 +1,16 @@
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LLink } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return
const links = [
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
// For each output link copy our value over the original widget value
for (const linkInfo of links) {
const node = this.graph?.getNodeById(linkInfo.target_id)
const input = node?.inputs[linkInfo.target_slot]
if (!input) {
console.warn('Unable to resolve node or input for link', linkInfo)
continue
}
const widgetName = input.widget?.name
if (!widgetName) {
console.warn('Invalid widget or widget name', input.widget)
continue
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) {
console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`)
continue
}
widget.value = v
widget.callback?.(
widget.value,
app.canvas,
node,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
}
applyFirstWidgetValueToGraph(this, extraLinks)
}
function onCustomComboCreated(this: LGraphNode) {

View File

@@ -517,16 +517,15 @@ export class SceneModelManager implements ModelManagerInterface {
model.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(model)
const pendingMaterialMode = this.materialMode
this.setupModelMaterials(model)
if (pendingMaterialMode !== 'original') {
this.setMaterialMode(pendingMaterialMode)
if (this.materialMode !== 'original') {
this.setMaterialMode(this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}
this.setupModelMaterials(model)
this.setupCamera(size)
}

View File

@@ -26,6 +26,8 @@ import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
controlValues?: TWidgetValue[]
@@ -43,49 +45,15 @@ export class PrimitiveNode extends LGraphNode {
}
override applyToGraph(extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return
const sourceWidget = this.widgets?.[0]
const graph = this.graph
if (!sourceWidget || !graph) return
const links = [
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
let v = sourceWidget.value
if (v && this.properties[replacePropertyName]) {
v = applyTextReplacements(this.graph, v as string)
}
// For each output link copy our value over the original widget value
for (const linkInfo of links) {
const node = this.graph?.getNodeById(linkInfo.target_id)
const input = node?.inputs[linkInfo.target_slot]
if (!input) {
console.warn('Unable to resolve node or input for link', linkInfo)
continue
}
const widgetName = input.widget?.name
if (!widgetName) {
console.warn('Invalid widget or widget name', input.widget)
continue
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) {
console.warn(
`Unable to find widget "${widgetName}" on node [${node.id}]`
)
continue
}
widget.value = v
widget.callback?.(
widget.value,
app.canvas,
node,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
v = applyTextReplacements(graph, v as string)
}
applyFirstWidgetValueToGraph(this, extraLinks, () => v)
}
override refreshComboInNode() {
@@ -98,7 +66,7 @@ export class PrimitiveNode extends LGraphNode {
if (!widget.options.values.includes(widget.value as string)) {
// @ts-expect-error fixme ts strict error
widget.value = widget.options.values[0]
;(widget.callback as Function)(widget.value)
widget.callback?.(widget.value)
}
}
}
@@ -273,7 +241,7 @@ export class PrimitiveNode extends LGraphNode {
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
let filter = this.widgets_values?.[2]
const filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter
}

View File

@@ -0,0 +1,127 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph_mouse: [0, 0]
}
}
}))
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
function createWidget(
name: string,
value: IBaseWidget['value'],
callback = vi.fn()
): IBaseWidget {
return fromPartial<IBaseWidget>({
name,
value,
callback
})
}
function createTargetNode(
widget: IBaseWidget,
id = 7
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
id,
inputs: [
fromPartial<INodeInputSlot>({
widget: { name: widget.name }
})
],
widgets: [widget]
})
}
function createLink(targetId: LLink['target_id'], targetSlot = 0): LLink {
return createMockLLink({
target_id: targetId,
target_slot: targetSlot
})
}
function createSourceNode(options: {
link: LLink
targetNode: Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
widgets?: IBaseWidget[]
}): SourceNode {
return {
graph: {
links: { 1: options.link },
getNodeById: vi.fn((id: LLink['target_id']) =>
id === options.targetNode.id ? options.targetNode : null
)
} as unknown as NonNullable<LGraphNode['graph']>,
outputs: [{ links: [1] } as INodeOutputSlot],
widgets: options.widgets ?? []
}
}
describe('applyFirstWidgetValueToGraph', () => {
it('returns early when the source widget is missing', () => {
const targetCallback = vi.fn()
const targetWidget = createWidget('value', 'unchanged', targetCallback)
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode
})
expect(() => applyFirstWidgetValueToGraph(sourceNode)).not.toThrow()
expect(targetWidget.value).toBe('unchanged')
expect(targetCallback).not.toHaveBeenCalled()
})
it('propagates the first widget value to the linked widget', () => {
const targetCallback = vi.fn()
const targetWidget = createWidget('value', 'old', targetCallback)
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode,
widgets: [createWidget('source', 'new value')]
})
applyFirstWidgetValueToGraph(sourceNode)
expect(targetWidget.value).toBe('new value')
expect(targetCallback).toHaveBeenCalledOnce()
expect(targetCallback).toHaveBeenCalledWith(
'new value',
expect.anything(),
targetNode,
[0, 0],
expect.anything()
)
})
it('applies a transform before propagating the widget value', () => {
const targetWidget = createWidget('value', 'old')
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode,
widgets: [createWidget('source', 'draft')]
})
applyFirstWidgetValueToGraph(sourceNode, [], (value) => `${value}-saved`)
expect(targetWidget.value).toBe('draft-saved')
})
})

View File

@@ -0,0 +1,68 @@
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { LLink } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { app } from '@/scripts/app'
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
export function applyFirstWidgetValueToGraph(
node: SourceNode,
extraLinks: LLink[] = [],
transformValue?: (value: TWidgetValue) => TWidgetValue
) {
const output = node.outputs[0]
if (!output?.links?.length || !node.graph) return
const sourceWidget = node.widgets?.[0]
if (!sourceWidget) return
let value = sourceWidget.value
if (transformValue) {
value = transformValue(value)
}
const graphMouse: Point = app.canvas?.graph_mouse ?? [0, 0]
const links = [
...output.links.map((linkId) => node.graph!.links[linkId]),
...extraLinks
]
for (const linkInfo of links) {
if (!linkInfo) continue
const targetNode = node.graph.getNodeById(linkInfo.target_id)
const input = targetNode?.inputs[linkInfo.target_slot]
if (!targetNode || !input) {
console.warn('Unable to resolve node or input for link', linkInfo)
continue
}
const widgetName = input.widget?.name
if (!widgetName) {
console.warn('Invalid widget or widget name', input.widget)
continue
}
const targetWidget = targetNode.widgets?.find(
(widget) => widget.name === widgetName
)
if (!targetWidget) {
console.warn(
`Unable to find widget "${widgetName}" on node [${targetNode.id}]`
)
continue
}
targetWidget.value = value
targetWidget.callback?.(
targetWidget.value,
app.canvas,
targetNode,
graphMouse,
{} as CanvasPointerEvent
)
}
}

View File

@@ -1,198 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {
static override type = TEST_NODE_TYPE
constructor(title?: string) {
super(title ?? TEST_NODE_TYPE)
this.type = TEST_NODE_TYPE
}
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
describe('cloned node z-index in Vue renderer', () => {
let graph: LGraph
let canvas: LGraphCanvas
let previousVueNodesMode: boolean
beforeEach(() => {
vi.clearAllMocks()
previousVueNodesMode = LiteGraph.vueNodesMode
LiteGraph.vueNodesMode = true
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
layoutStore.initializeFromLiteGraph([])
// Simulate Vue runtime: create layout entries when nodes are added
graph.onNodeAdded = (node: LGraphNode) => {
createLayoutEntry(node, 0)
}
})
afterEach(() => {
LiteGraph.vueNodesMode = previousVueNodesMode
})
it('places cloned nodes above the original node z-index', () => {
const originalNode = new TestNode()
originalNode.pos = [100, 100]
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
setZIndex(originalNodeId, 5, 0)
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
expect(originalLayout?.zIndex).toBe(5)
// Clone the node via cloneNodes (same path as right-click > clone)
const result = LGraphCanvas.cloneNodes([originalNode])
expect(result).toBeDefined()
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
expect(clonedLayout).toBeDefined()
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
})
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
const nodeA = new TestNode()
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
expect(result!.created.length).toBe(2)
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)
expect(layoutB.zIndex).toBeGreaterThan(7)
// Each cloned node should have a distinct z-index
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
})
})

View File

@@ -7,7 +7,6 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -4285,17 +4284,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
// Bring cloned/pasted nodes to front so they render above the originals
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
}
const { setNodeZIndex } = useLayoutMutations()
for (let i = 0; i < newPositions.length; i++) {
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
}
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())

View File

@@ -186,16 +186,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
if (!widget) return
// Special case: SubgraphNode widget.
// Prefer serializeValue (per-instance) over the shared .value getter
// so multiple SubgraphNode instances return their own configured values.
const widgetValue = widget.serializeValue
? widget.serializeValue(subgraphNode, -1)
: widget.value
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widgetValue }
widgetInfo: { value: widget.value }
}
}

View File

@@ -1,261 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function createNodeWithWidget(
title: string,
widgetValue: number = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget, input }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('keeps fresh sibling instances isolated before save or reload', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 7)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
instance1.graph!.add(instance1)
instance2.graph!.add(instance2)
const widget1 = instance1.widgets?.[0]
const widget2 = instance2.widgets?.[0]
expect(widget1?.value).toBe(7)
expect(widget2?.value).toBe(7)
widget1!.value = 10
expect(widget1?.value).toBe(10)
expect(widget2?.value).toBe(7)
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
})
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
originalInstance.configure({
id: 601,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
restoredInstance.configure({
...serialized,
id: 602,
type: subgraph.id
})
expect(restoredInstance.widgets?.[0].value).toBe(33)
widget.value = 45
expect(restoredInstance.widgets?.[0].value).toBe(45)
expect(
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
).toBe(45)
})
it('clears stale per-instance values when reconfigured without widgets_values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
instance.graph!.add(instance)
const promotedWidget = instance.widgets?.[0]
promotedWidget!.value = 11
widget.value = 17
const serialized = instance.serialize()
delete serialized.widgets_values
instance.configure({
...serialized,
id: instance.id,
type: subgraph.id
})
expect(instance.widgets?.[0].value).toBe(17)
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
})

View File

@@ -994,21 +994,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
private _pendingWidgetsValues?: unknown[]
/**
* Per-instance promoted widget values.
* Multiple SubgraphNode instances share the same inner nodes, so
* promoted widget values must be stored per-instance to avoid collisions.
* Key: `${sourceNodeId}:${sourceWidgetName}`
*/
readonly _instanceWidgetValues = new Map<string, unknown>()
override configure(info: ExportedSubgraphInstance): void {
this._instanceWidgetValues.clear()
this._pendingWidgetsValues = info.widgets_values
for (const input of this.inputs) {
if (
input._listenerController &&
@@ -1139,21 +1125,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
// Hydrate per-instance promoted widget values from serialized data.
// LGraphNode.configure skips promoted widgets (serialize === false on
// the view), so they must be applied here after promoted views exist.
// Only iterate serializable views to match what serialize() wrote.
if (this._pendingWidgetsValues) {
const views = this._getPromotedViews()
let i = 0
for (const view of views) {
if (!view.sourceSerialize) continue
if (i >= this._pendingWidgetsValues.length) break
view.value = this._pendingWidgetsValues[i++] as typeof view.value
}
this._pendingWidgetsValues = undefined
}
}
/**
@@ -1548,7 +1519,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
this._eventAbortController.abort()
this._invalidatePromotedViewsCache()
this._instanceWidgetValues.clear()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
@@ -1604,7 +1574,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
@@ -1612,22 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
this.properties.proxyWidgets = this._serializeEntries(entries)
const serialized = super.serialize()
const views = this._getPromotedViews()
const serializableViews = views.filter((view) => view.sourceSerialize)
if (serializableViews.length > 0) {
serialized.widgets_values = serializableViews.map((view) => {
const value = view.serializeValue
? view.serializeValue(this, -1)
: view.value
return value != null && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: (value ?? null)
})
}
return serialized
return super.serialize()
}
override clone() {
const clone = super.clone()

View File

@@ -2707,7 +2707,6 @@
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
"blueprint": "Blueprint",
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
@@ -2763,55 +2762,51 @@
"survey": {
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
"intro": "Help us tailor your ComfyUI experience.",
"errors": {
"chooseAnOption": "Please choose an option.",
"selectAtLeastOne": "Please select at least one option.",
"describeAnswer": "Please describe your answer."
},
"steps": {
"usage": "How do you plan to use ComfyUI?",
"familiarity": "How familiar are you with ComfyUI?",
"intent": "What do you want to create with ComfyUI?",
"source": "Where did you hear about ComfyUI?"
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"questions": {
"familiarity": "How familiar are you with ComfyUI?",
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"options": {
"usage": {
"personal": "Personal use",
"work": "Work",
"education": "Education (student or educator)"
},
"familiarity": {
"new": "New never used it",
"starting": "Beginner — following tutorials",
"basics": "Intermediate — comfortable with basics",
"advanced": "Advanced — build and edit workflows",
"expert": "Expert — I help others"
"new": "New to ComfyUI (never used it before)",
"starting": "Just getting started (following tutorials)",
"basics": "Comfortable with basics",
"advanced": "Advanced user (custom workflows)",
"expert": "Expert (help others)"
},
"intent": {
"workflows": "Custom workflows or pipelines",
"custom_nodes": "Custom nodes",
"videos": "Videos",
"purpose": {
"personal": "Personal projects / hobby",
"community": "Community contributions (nodes, workflows, etc.)",
"client": "Client work (freelance)",
"inhouse": "My own workplace (in-house)",
"research": "Academic research"
},
"industry": {
"film_tv_animation": "Film, TV, & animation",
"gaming": "Gaming",
"marketing": "Marketing & advertising",
"architecture": "Architecture",
"product_design": "Product & graphic design",
"fine_art": "Fine art & illustration",
"software": "Software & technology",
"education": "Education",
"other": "Other",
"otherPlaceholder": "Please specify"
},
"making": {
"images": "Images",
"3d_game": "3D assets / game assets",
"video": "Video & animation",
"3d": "3D assets",
"audio": "Audio / music",
"apps": "Simplified Apps from workflows",
"api": "API endpoints to run workflows",
"not_sure": "Not sure"
},
"source": {
"youtube": "YouTube",
"reddit": "Reddit",
"twitter": "Twitter / X",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"friend": "Friend or colleague",
"search": "Google / search",
"newsletter": "Newsletter or blog",
"conference": "Conference or event",
"discord": "Discord / community",
"github": "GitHub",
"other": "Other"
"custom_nodes": "Custom nodes & workflows"
}
}
},
@@ -2881,10 +2876,10 @@
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
"cloudSurvey_steps_industry": "What's your primary industry?",
"cloudSurvey_steps_making": "What do you plan on making?",
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",

View File

@@ -75,7 +75,7 @@ export const Default: Story = {
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:override-assets="assets"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
@@ -116,7 +116,7 @@ export const SingleAssetType: Story = {
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:override-assets="assets"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
@@ -159,7 +159,7 @@ export const NoLeftPanel: Story = {
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:override-assets="assets"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>

View File

@@ -117,12 +117,6 @@ const props = defineProps<{
onClose?: () => void
showLeftPanel?: boolean
title?: string
/**
* Storybook/test seam: when provided, bypasses the cloud-only
* `assetsStore.getAssets(cacheKey)` fetch and renders this list directly.
* Production callers should leave this undefined and rely on the store.
*/
overrideAssets?: AssetItem[]
}>()
const emit = defineEmits<{
@@ -138,9 +132,7 @@ const cacheKey = computed(() => {
return ''
})
const fetchedAssets = computed(
() => props.overrideAssets ?? assetStore.getAssets(cacheKey.value)
)
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
@@ -149,7 +141,6 @@ const isLoading = computed(
)
async function refreshAssets(): Promise<void> {
if (props.overrideAssets) return
if (props.nodeType) {
await assetStore.updateModelsForNodeType(props.nodeType)
} else if (props.assetType) {

View File

@@ -1,8 +1,6 @@
/* eslint-disable vue/one-component-per-file */
import { render } from '@testing-library/vue'
import type { MenuItem } from 'primevue/menuitem'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PropType } from 'vue'
import { defineComponent, nextTick, onMounted, ref } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
@@ -33,7 +31,6 @@ vi.mock('@/utils/loaderNodeUtil', () => ({
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAsset: vi.fn(),
downloadMultipleAssets: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
@@ -44,18 +41,12 @@ vi.mock('../composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActions
}))
const capturedMenu = vi.hoisted(() => ({ model: [] as MenuItem[] }))
const contextMenuStub = defineComponent({
name: 'ContextMenu',
props: {
pt: {
type: Object,
default: undefined
},
model: {
type: Array as PropType<MenuItem[]>,
default: () => []
}
},
emits: ['hide'],
@@ -64,14 +55,6 @@ const contextMenuStub = defineComponent({
visible: false
}
},
watch: {
model: {
immediate: true,
handler(items: MenuItem[]) {
capturedMenu.model = items
}
}
},
methods: {
show() {
this.visible = true
@@ -145,24 +128,9 @@ async function showMenu(container: Element): Promise<HTMLElement> {
afterEach(() => {
vi.clearAllMocks()
capturedRef = null
capturedMenu.model = []
document.body.innerHTML = ''
})
type MenuItemWithCommand = MenuItem & {
command: NonNullable<MenuItem['command']>
}
function findDownloadMenuItem(): MenuItemWithCommand {
const downloadItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.download'
)
if (!downloadItem?.command) {
throw new Error('Download menu item or command was not registered')
}
return downloadItem as MenuItemWithCommand
}
describe('MediaAssetContextMenu', () => {
it('dismisses outside pointerdown using the rendered root id', async () => {
const { container, unmount, onHide } = mountComponent()
@@ -185,22 +153,4 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('routes Download through downloadMultipleAssets so multi-output jobs zip', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)
const downloadItem = findDownloadMenuItem()
downloadItem.command({
originalEvent: new MouseEvent('click'),
item: downloadItem
})
expect(mediaAssetActions.downloadMultipleAssets).toHaveBeenCalledWith([
asset
])
expect(mediaAssetActions.downloadAsset).not.toHaveBeenCalled()
unmount()
})
})

View File

@@ -217,7 +217,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
items.push({
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
command: () => actions.downloadMultipleAssets([asset])
command: () => actions.downloadAsset(asset)
})
// Separator before workflow actions (only if there are workflow actions)

View File

@@ -117,7 +117,7 @@ const DialogDemoComponent = {
>
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
<AssetBrowserModal
:override-assets="mockAssets"
:assets="mockAssets"
:node-type="currentNodeType"
:input-name="currentInputName"
:current-value="currentValue"

View File

@@ -173,7 +173,7 @@ describe('useAssetBrowserDialog', () => {
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.nodeType).toBe('CheckpointLoaderSimple')
expect(dialogCall.props.overrideAssets).toBeUndefined()
expect(dialogCall.props.assets).toBeUndefined()
})
})
})

View File

@@ -1,40 +1,251 @@
<template>
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
<DynamicSurveyForm
:key="activeSurvey.version"
:survey="activeSurvey"
:is-submitting="isSubmitting"
@submit="onSubmitSurvey"
/>
<div>
<Stepper
value="1"
class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col"
>
<ProgressBar
:value="progressPercent"
:show-value="false"
class="mb-8 h-2"
/>
<StepPanels class="flex flex-1 flex-col p-0">
<StepPanel
v-slot="{ activateCallback }"
value="1"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_familiarity')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in familiarityOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.familiarity"
:input-id="`fam-${opt.value}`"
name="familiarity"
:value="opt.value"
/>
<label
:for="`fam-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex justify-between pt-4">
<span />
<Button
:disabled="!validStep1"
class="h-10 w-full border-none text-white"
@click="goTo(2, activateCallback)"
>
{{ $t('g.next') }}
</Button>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="2"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_purpose')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in purposeOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.useCase"
:input-id="`purpose-${opt.value}`"
name="purpose"
:value="opt.value"
/>
<label
:for="`purpose-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.useCaseOther"
class="w-full"
:placeholder="
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
variant="secondary"
class="flex-1 text-white"
@click="goTo(1, activateCallback)"
>
{{ $t('g.back') }}
</Button>
<Button
:disabled="!validStep2"
class="h-10 flex-1 text-white"
@click="goTo(3, activateCallback)"
>
{{ $t('g.next') }}
</Button>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="3"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_industry')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in industryOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.industry"
:input-id="`industry-${opt.value}`"
name="industry"
:value="opt.value"
/>
<label
:for="`industry-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.industry === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.industryOther"
class="w-full"
:placeholder="
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
variant="secondary"
class="flex-1 text-white"
@click="goTo(2, activateCallback)"
>
{{ $t('g.back') }}
</Button>
<Button
:disabled="!validStep3"
class="h-10 flex-1 border-none text-white"
@click="goTo(4, activateCallback)"
>
{{ $t('g.next') }}
</Button>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="4"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_making')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in makingOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<Checkbox
v-model="surveyData.making"
:input-id="`making-${opt.value}`"
:value="opt.value"
/>
<label
:for="`making-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
variant="secondary"
class="flex-1 text-white"
@click="goTo(3, activateCallback)"
>
{{ $t('g.back') }}
</Button>
<Button
:disabled="!validStep4 || isSubmitting"
:loading="isSubmitting"
class="h-10 flex-1 border-none text-white"
@click="onSubmitSurvey"
>
{{ $t('g.submit') }}
</Button>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import ProgressBar from 'primevue/progressbar'
import RadioButton from 'primevue/radiobutton'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
getSurveyCompletedStatus,
submitSurvey
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useTelemetry } from '@/platform/telemetry'
import DynamicSurveyForm from './survey/DynamicSurveyForm.vue'
import { defaultOnboardingSurvey } from './survey/defaultSurveySchema'
const { t } = useI18n()
const router = useRouter()
const { flags } = useFeatureFlags()
const onboardingSurveyEnabled = computed(() => flags.onboardingSurveyEnabled)
const activeSurvey = computed(
() => remoteConfig.value.onboarding_survey ?? defaultOnboardingSurvey
)
const isSubmitting = ref(false)
// Check if survey is already completed on mount
onMounted(async () => {
if (!onboardingSurveyEnabled.value) {
await router.replace({ name: 'cloud-user-check' })
@@ -43,31 +254,156 @@ onMounted(async () => {
try {
const surveyCompleted = await getSurveyCompletedStatus()
if (surveyCompleted) {
// User already completed survey, return to onboarding flow
await router.replace({ name: 'cloud-user-check' })
return
}
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
} else {
// Track survey opened event
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
}
}
} catch (error) {
console.error('Failed to check survey status:', error)
}
})
const onSubmitSurvey = async (payload: Record<string, unknown>) => {
if (!onboardingSurveyEnabled.value) {
await router.replace({ name: 'cloud-user-check' })
return
const activeStep = ref(1)
const totalSteps = 4
const progressPercent = computed(() =>
Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100))
)
const isSubmitting = ref(false)
const surveyData = ref({
familiarity: '',
useCase: '',
useCaseOther: '',
industry: '',
industryOther: '',
making: [] as string[]
})
// Options
const familiarityOptions = [
{ label: 'New to ComfyUI (never used it before)', value: 'new' },
{ label: 'Just getting started (following tutorials)', value: 'starting' },
{ label: 'Comfortable with basics', value: 'basics' },
{ label: 'Advanced user (custom workflows)', value: 'advanced' },
{ label: 'Expert (help others)', value: 'expert' }
]
const purposeOptions = [
{ label: 'Personal projects/hobby', value: 'personal' },
{
label: 'Community contributions (nodes, workflows, etc.)',
value: 'community'
},
{ label: 'Client work (freelance)', value: 'client' },
{ label: 'My own workplace (in-house)', value: 'inhouse' },
{ label: 'Academic research', value: 'research' },
{ label: 'Other', value: 'other' }
]
const industryOptions = [
{ label: 'Film, TV, & animation', value: 'film_tv_animation' },
{ label: 'Gaming', value: 'gaming' },
{ label: 'Marketing & advertising', value: 'marketing' },
{ label: 'Architecture', value: 'architecture' },
{ label: 'Product & graphic design', value: 'product_design' },
{ label: 'Fine art & illustration', value: 'fine_art' },
{ label: 'Software & technology', value: 'software' },
{ label: 'Education', value: 'education' },
{ label: 'Other', value: 'other' }
]
const makingOptions = [
{ label: 'Images', value: 'images' },
{ label: 'Video & animation', value: 'video' },
{ label: '3D assets', value: '3d' },
{ label: 'Audio/music', value: 'audio' },
{ label: 'Custom nodes & workflows', value: 'custom_nodes' }
]
// Validation per step
const validStep1 = computed(() => !!surveyData.value.familiarity)
const validStep2 = computed(() => {
if (!surveyData.value.useCase) return false
if (surveyData.value.useCase === 'other') {
return !!surveyData.value.useCaseOther?.trim()
}
isSubmitting.value = true
return true
})
const validStep3 = computed(() => {
if (!surveyData.value.industry) return false
if (surveyData.value.industry === 'other') {
return !!surveyData.value.industryOther?.trim()
}
return true
})
const validStep4 = computed(() => surveyData.value.making.length > 0)
const changeActiveStep = (step: number) => {
activeStep.value = step
}
const goTo = (step: number, activate: (val: string | number) => void) => {
// keep Stepper panel and progress bar in sync; Stepper values are strings
changeActiveStep(step)
activate(String(step))
}
// Submit
const onSubmitSurvey = async () => {
try {
await submitSurvey(payload)
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', payload)
if (!onboardingSurveyEnabled.value) {
await router.replace({ name: 'cloud-user-check' })
return
}
isSubmitting.value = true
// prepare payload with consistent structure
const payload = {
familiarity: surveyData.value.familiarity,
useCase:
surveyData.value.useCase === 'other'
? surveyData.value.useCaseOther?.trim() || 'other'
: surveyData.value.useCase,
industry:
surveyData.value.industry === 'other'
? surveyData.value.industryOther?.trim() || 'other'
: surveyData.value.industry,
making: surveyData.value.making
}
await submitSurvey(payload)
// Track survey submitted event with responses
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', {
industry: payload.industry,
useCase: payload.useCase,
familiarity: payload.familiarity,
making: payload.making
})
}
await router.push({ name: 'cloud-user-check' })
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
:deep(.p-progressbar .p-progressbar-value) {
background-color: #f0ff41 !important;
}
:deep(.p-radiobutton-checked .p-radiobutton-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -1,161 +0,0 @@
<template>
<fieldset
v-if="field.type !== 'text'"
:aria-invalid="Boolean(errorMessage)"
class="flex flex-col gap-4 border-0 p-0"
>
<legend class="mb-2 block text-lg font-medium text-base-foreground">
{{ resolvedLabel }}
</legend>
<template v-if="field.type === 'single'">
<div
v-for="option in field.options"
:key="option.value"
class="flex items-center gap-3"
>
<RadioButton
:model-value="(modelValue as string) ?? ''"
:input-id="`${field.id}-${option.value}`"
:name="field.id"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onSingleChange"
/>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<template v-else>
<div
v-for="option in field.options"
:key="option.value"
class="flex items-center gap-3"
>
<Checkbox
:model-value="(modelValue as string[]) ?? []"
:input-id="`${field.id}-${option.value}`"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onMultiChange"
/>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<Input
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
:model-value="(otherValue as string) ?? ''"
:placeholder="
$t(
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
$t('cloudOnboarding.survey.otherPlaceholder')
)
"
class="ml-1"
@update:model-value="onOtherChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
</fieldset>
<div v-else class="flex flex-col gap-3">
<label
:for="controlId"
class="block text-lg font-medium text-base-foreground"
>
{{ resolvedLabel }}
</label>
<Input
:id="controlId"
:model-value="(modelValue as string) ?? ''"
:placeholder="field.placeholder"
:aria-invalid="Boolean(errorMessage)"
@update:model-value="onTextChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Input from '@/components/ui/input/Input.vue'
import type {
LocalizedString,
OnboardingSurveyField,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
const {
field,
modelValue,
otherValue,
errorMessage = ''
} = defineProps<{
field: OnboardingSurveyField
modelValue: string | string[] | undefined
otherValue?: string
errorMessage?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
'update:otherValue': [value: string]
}>()
const { t, te, locale } = useI18n()
const controlId = useId()
const resolveLocalized = (value: LocalizedString): string => {
if (typeof value === 'string') return value
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
}
const checkedTokens = {
checked: {
background: 'var(--color-electric-400)',
borderColor: 'var(--color-electric-400)',
hoverBackground: 'var(--color-electric-400)',
hoverBorderColor: 'var(--color-electric-400)'
}
}
const resolvedLabel = (() => {
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
if (field.label != null) return resolveLocalized(field.label)
return field.id
})()
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
if (option.label != null) return resolveLocalized(option.label)
return option.value
}
const onSingleChange = (value: unknown) => {
emit('update:modelValue', typeof value === 'string' ? value : '')
}
const onMultiChange = (value: unknown) => {
if (!Array.isArray(value)) {
emit('update:modelValue', [])
return
}
emit(
'update:modelValue',
value.filter((v): v is string => typeof v === 'string')
)
}
const onTextChange = (value: string | number | undefined) => {
emit('update:modelValue', String(value ?? ''))
}
const onOtherChange = (value: string | number | undefined) => {
emit('update:otherValue', String(value ?? ''))
}
</script>

View File

@@ -1,320 +0,0 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyForm from './DynamicSurveyForm.vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: 'Please choose an option.',
selectAtLeastOne: 'Please select at least one option.',
describeAnswer: 'Please describe your answer.'
}
}
}
}
}
})
const renderForm = (survey: OnboardingSurvey) =>
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
props: { survey }
})
const twoStepSurvey: OnboardingSurvey = {
version: 1,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'usage',
type: 'single',
label: 'How do you plan to use ComfyUI?',
required: true,
options: [
{ value: 'personal', label: 'Personal use' },
{ value: 'work', label: 'Work' }
]
},
{
id: 'intent',
type: 'multi',
label: 'What do you want to create with ComfyUI?',
required: true,
options: [
{ value: 'images', label: 'Images' },
{ value: 'videos', label: 'Videos' }
]
}
]
}
describe('DynamicSurveyForm', () => {
it('renders the intro text and the first field options', () => {
renderForm(twoStepSurvey)
expect(
screen.getByText('Help us tailor your ComfyUI experience.')
).toBeInTheDocument()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
expect(screen.getByLabelText('Work')).toBeInTheDocument()
})
it('disables Next until the user selects an option, then advances', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeDisabled()
await user.click(screen.getByLabelText('Personal use'))
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
expect(
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
expect(screen.getByLabelText('Images')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
})
it('navigates back to the previous step', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await user.click(screen.getByLabelText('Personal use'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
expect(
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Back' }))
await flushPromises()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
})
it('resolves option and field labels via labelKey when provided', () => {
const localizedI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
},
survey_label: 'Localized question?',
survey_a: 'Localized A',
survey_b: 'Localized B'
}
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, localizedI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
labelKey: 'survey_label',
required: true,
options: [
{ value: 'a', labelKey: 'survey_a' },
{ value: 'b', labelKey: 'survey_b' }
]
}
]
}
}
})
expect(screen.getByText('Localized question?')).toBeVisible()
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
})
it('renders server-supplied translations from a label locale map', () => {
const koreanI18n = createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, koreanI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'usage',
type: 'single',
label: {
en: 'How will you use it?',
ko: '어떻게 사용하시겠어요?'
},
required: true,
options: [
{
value: 'personal',
label: { en: 'Personal use', ko: '개인 용도' }
},
{ value: 'work', label: { en: 'Work', ko: '업무' } }
]
}
]
}
}
})
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
expect(screen.getByLabelText('업무')).toBeInTheDocument()
})
it('falls back to English when current locale missing from label map', () => {
const fallbackI18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
fr: {}
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, fallbackI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
label: { en: 'English question', ko: '한국어' },
required: true,
options: [
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
]
}
]
}
}
})
// fr is not in the map → falls back to en
expect(screen.getByText('English question')).toBeVisible()
expect(screen.getByLabelText('English A')).toBeInTheDocument()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
]
}
}
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
expect(screen.getByText('Required question?')).toBeVisible()
})
it('enables Submit only after the multi-select field has at least one choice', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await user.click(screen.getByLabelText('Work'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
const submitBtn = screen.getByRole('button', { name: 'Submit' })
expect(submitBtn).toBeDisabled()
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
await flushPromises()
expect(submitBtn).toBeEnabled()
})
})

View File

@@ -1,212 +0,0 @@
<template>
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted">
{{ introText }}
</p>
<div
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
>
<div
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="
errors[currentField.id] ??
(currentField.otherFieldId
? errors[currentField.otherFieldId]
: undefined)
"
@update:model-value="(value) => onFieldChange(currentField.id, value)"
@update:other-value="
(value) =>
currentField.otherFieldId &&
onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
v-if="!isFirst"
type="button"
variant="secondary"
class="h-10 flex-1 text-white"
@click="goPrevious"
>
{{ $t('g.back') }}
</Button>
<span v-else class="flex-1" />
<Button
v-if="!isLast"
type="button"
:disabled="!isCurrentValid"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
@click="goNext"
>
{{ $t('g.next') }}
</Button>
<Button
v-else
type="submit"
:disabled="!isCurrentValid || isSubmitting"
:loading="isSubmitting"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid && !isSubmitting
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
>
{{ $t('g.submit') }}
</Button>
</div>
</form>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
import {
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
prepareSurvey,
visibleFields
} from './surveySchema'
import type { SurveyValues } from './surveySchema'
const { survey } = defineProps<{
survey: OnboardingSurvey
isSubmitting?: boolean
}>()
const emit = defineEmits<{
submit: [payload: Record<string, unknown>]
}>()
const { t, te } = useI18n()
const preparedSurvey = computed(() => prepareSurvey(survey))
const introText = computed(() => {
const key = preparedSurvey.value.introKey
if (!key) return ''
return te(key) ? t(key) : ''
})
const liveValues = ref<SurveyValues>(buildInitialValues(preparedSurvey.value))
const validationSchema = computed(() =>
toTypedSchema(buildZodSchema(preparedSurvey.value, liveValues.value, t))
)
const { values, errors, setFieldValue, validate, resetForm } =
useForm<SurveyValues>({
initialValues: liveValues.value,
validationSchema
})
watch(
() => survey,
() => {
const fresh = buildInitialValues(preparedSurvey.value)
liveValues.value = { ...fresh }
resetForm({ values: fresh })
stepIndex.value = 0
}
)
const visible = computed(() =>
visibleFields(preparedSurvey.value, values as SurveyValues)
)
const stepIndex = ref(0)
const currentField = computed(() => visible.value[stepIndex.value])
const isFirst = computed(() => stepIndex.value === 0)
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
const totalSteps = computed(() => Math.max(visible.value.length, 1))
const progressPercent = computed(() =>
Math.max(
100 / totalSteps.value,
((stepIndex.value + 1) / totalSteps.value) * 100
)
)
const isCurrentValid = computed(() => {
const field = currentField.value
if (!field) return false
const value = values[field.id]
const isEmpty =
field.type === 'multi'
? !Array.isArray(value) || value.length === 0
: typeof value !== 'string' || value.length === 0
if (isEmpty) return !field.required
if (field.allowOther && field.otherFieldId && value === 'other') {
const other = values[field.otherFieldId]
return typeof other === 'string' && other.trim().length > 0
}
return true
})
const onFieldChange = (id: string, value: string | string[]) => {
setFieldValue(id, value)
liveValues.value = { ...liveValues.value, [id]: value }
if (stepIndex.value > visible.value.length - 1) {
stepIndex.value = Math.max(0, visible.value.length - 1)
}
}
const goNext = () => {
if (stepIndex.value < visible.value.length - 1) stepIndex.value += 1
}
const goPrevious = () => {
if (stepIndex.value > 0) stepIndex.value -= 1
}
const onSubmit = async () => {
const result = await validate()
if (!result.valid) return
emit(
'submit',
buildSubmissionPayload(preparedSurvey.value, values as SurveyValues)
)
}
</script>

View File

@@ -1,76 +0,0 @@
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
const optionsFor = (
fieldId: string,
values: string[]
): { value: string; labelKey: string }[] =>
values.map((value) => ({
value,
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
}))
export const defaultOnboardingSurvey: OnboardingSurvey = {
version: 2,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'usage',
type: 'single',
labelKey: 'cloudSurvey_steps_usage',
required: true,
options: optionsFor('usage', ['personal', 'work', 'education'])
},
{
id: 'familiarity',
type: 'single',
labelKey: 'cloudSurvey_steps_familiarity',
required: true,
options: optionsFor('familiarity', [
'new',
'starting',
'basics',
'advanced',
'expert'
])
},
{
id: 'intent',
type: 'multi',
labelKey: 'cloudSurvey_steps_intent',
required: true,
randomize: true,
options: optionsFor('intent', [
'workflows',
'custom_nodes',
'videos',
'images',
'3d_game',
'audio',
'apps',
'api',
'not_sure'
])
},
{
id: 'source',
type: 'single',
labelKey: 'cloudSurvey_steps_source',
required: true,
randomize: true,
options: optionsFor('source', [
'youtube',
'reddit',
'twitter',
'instagram',
'linkedin',
'friend',
'search',
'newsletter',
'conference',
'discord',
'github',
'other'
])
}
]
}

View File

@@ -1,248 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import {
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
prepareSurvey,
visibleFields
} from './surveySchema'
const baseSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'usage',
type: 'single',
required: true,
options: [
{ value: 'work', label: 'Work' },
{ value: 'personal', label: 'Personal' }
]
},
{
id: 'role',
type: 'single',
required: true,
showWhen: { field: 'usage', equals: 'work' },
options: [{ value: 'engineer', label: 'Engineer' }]
},
{
id: 'industry',
type: 'single',
required: true,
allowOther: true,
otherFieldId: 'industryOther',
showWhen: { field: 'usage', equals: 'work' },
options: [
{ value: 'tech', label: 'Tech' },
{ value: 'other', label: 'Other' }
]
},
{
id: 'making',
type: 'multi',
required: true,
options: [
{ value: 'video', label: 'Video' },
{ value: 'images', label: 'Images' }
]
}
]
}
describe('visibleFields', () => {
it('hides fields when showWhen does not match', () => {
const visible = visibleFields(baseSurvey, { usage: 'personal' })
expect(visible.map((f) => f.id)).toEqual(['usage', 'making'])
})
it('shows gated fields when showWhen matches', () => {
const visible = visibleFields(baseSurvey, { usage: 'work' })
expect(visible.map((f) => f.id)).toEqual([
'usage',
'role',
'industry',
'making'
])
})
it('treats array equals as membership', () => {
const survey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'role',
type: 'single',
showWhen: { field: 'usage', equals: ['work', 'education'] }
}
]
}
expect(visibleFields(survey, { usage: 'education' })).toHaveLength(1)
expect(visibleFields(survey, { usage: 'personal' })).toHaveLength(0)
})
it('intersects multi-select source values with expected set', () => {
const survey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'follow_up',
type: 'single',
showWhen: { field: 'making', equals: ['video', '3d'] }
}
]
}
expect(visibleFields(survey, { making: [] })).toHaveLength(0)
expect(visibleFields(survey, { making: ['images'] })).toHaveLength(0)
expect(visibleFields(survey, { making: ['images', 'video'] })).toHaveLength(
1
)
})
})
describe('buildInitialValues', () => {
it('initializes single fields to empty string and multi to empty array', () => {
expect(buildInitialValues(baseSurvey)).toMatchObject({
usage: '',
role: '',
industry: '',
industryOther: '',
making: []
})
})
})
describe('buildZodSchema', () => {
it('omits hidden fields from validation', () => {
const schema = buildZodSchema(baseSurvey, { usage: 'personal' })
const result = schema.safeParse({ usage: 'personal', making: ['video'] })
expect(result.success).toBe(true)
})
it('requires gated fields once visible', () => {
const schema = buildZodSchema(baseSurvey, { usage: 'work' })
const result = schema.safeParse({ usage: 'work', making: ['video'] })
expect(result.success).toBe(false)
})
it('requires "other" detail when option is selected', () => {
const schema = buildZodSchema(baseSurvey, {
usage: 'work',
role: 'engineer',
industry: 'other',
making: ['video']
})
expect(
schema.safeParse({
usage: 'work',
role: 'engineer',
industry: 'other',
industryOther: '',
making: ['video']
}).success
).toBe(false)
expect(
schema.safeParse({
usage: 'work',
role: 'engineer',
industry: 'other',
industryOther: 'Aerospace',
making: ['video']
}).success
).toBe(true)
})
})
describe('buildSubmissionPayload', () => {
it('clears hidden fields and prefers free-text "other" detail', () => {
const payload = buildSubmissionPayload(baseSurvey, {
usage: 'work',
role: 'engineer',
industry: 'other',
industryOther: ' Aerospace ',
making: ['video']
})
expect(payload).toEqual({
usage: 'work',
role: 'engineer',
industry: 'Aerospace',
making: ['video']
})
})
it('falls back to "other" when free-text is empty', () => {
const payload = buildSubmissionPayload(baseSurvey, {
usage: 'work',
role: 'engineer',
industry: 'other',
industryOther: '',
making: ['video']
})
expect(payload.industry).toBe('other')
})
it('zeroes out fields hidden by showWhen', () => {
const payload = buildSubmissionPayload(baseSurvey, {
usage: 'personal',
role: 'engineer',
making: ['video']
})
expect(payload).toMatchObject({
usage: 'personal',
role: '',
industry: '',
making: ['video']
})
})
})
describe('prepareSurvey', () => {
it('preserves option contents but may reorder when randomize=true', () => {
const survey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'making',
type: 'multi',
randomize: true,
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'other', label: 'Other' }
]
}
]
}
const prepared = prepareSurvey(survey)
const values = prepared.fields[0]!.options!.map((o) => o.value)
expect(values).toContain('a')
expect(values).toContain('b')
expect(values[values.length - 1]).toBe('other')
})
it('pins both "other" and "not_sure" at the end while randomizing the rest', () => {
const survey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'intent',
type: 'multi',
randomize: true,
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'other', label: 'Other' },
{ value: 'not_sure', label: 'Not sure' }
]
}
]
}
const prepared = prepareSurvey(survey)
const values = prepared.fields[0]!.options!.map((o) => o.value)
expect(values.slice(-2).sort()).toEqual(['not_sure', 'other'])
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
})
})

View File

@@ -1,137 +0,0 @@
import { shuffle } from 'es-toolkit'
import { z } from 'zod'
import type {
OnboardingSurvey,
OnboardingSurveyField,
OnboardingSurveyFieldCondition
} from '@/platform/remoteConfig/types'
export type SurveyValues = Record<string, string | string[] | undefined>
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
if (current === undefined || current === '') return false
if (Array.isArray(current)) return current.length > 0
return true
}
const conditionMatches = (
condition: OnboardingSurveyFieldCondition | undefined,
values: SurveyValues
): boolean => {
if (!condition) return true
const current = values[condition.field]
if (!hasNonEmptyValue(current)) return false
const expected = condition.equals
if (expected === undefined) return true
const expectedSet = Array.isArray(expected) ? expected : [expected]
if (Array.isArray(current)) {
return current.some((v) => expectedSet.includes(v))
}
return typeof current === 'string' && expectedSet.includes(current)
}
export const visibleFields = (
survey: OnboardingSurvey,
values: SurveyValues
): OnboardingSurveyField[] =>
survey.fields.filter((field) => conditionMatches(field.showWhen, values))
const PIN_LAST_VALUES = new Set(['other', 'not_sure'])
const randomizeOptions = (field: OnboardingSurveyField) => {
if (!field.randomize || !field.options) return field
const pinned = field.options.filter((opt) => PIN_LAST_VALUES.has(opt.value))
const rest = field.options.filter((opt) => !PIN_LAST_VALUES.has(opt.value))
return {
...field,
options: [...shuffle(rest), ...pinned]
}
}
export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
...survey,
fields: survey.fields.map(randomizeOptions)
})
type Translator = (key: string) => string
const identityTranslator: Translator = (key) => key
const fieldSchema = (field: OnboardingSurveyField, t: Translator) => {
if (field.type === 'multi') {
const arr = z.array(z.string())
return field.required
? arr.min(1, {
message: t('cloudOnboarding.survey.errors.selectAtLeastOne')
})
: arr.optional()
}
if (field.required) {
return z.string().min(1, {
message: t('cloudOnboarding.survey.errors.chooseAnOption')
})
}
return z.string().optional()
}
export const buildZodSchema = (
survey: OnboardingSurvey,
values: SurveyValues,
t: Translator = identityTranslator
) => {
const shape: Record<string, z.ZodTypeAny> = {}
for (const field of survey.fields) {
if (!conditionMatches(field.showWhen, values)) continue
shape[field.id] = fieldSchema(field, t)
if (
field.allowOther &&
field.otherFieldId &&
values[field.id] === 'other'
) {
shape[field.otherFieldId] = z.string().min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
} else if (field.otherFieldId) {
shape[field.otherFieldId] = z.string().optional()
}
}
return z.object(shape)
}
export const buildInitialValues = (survey: OnboardingSurvey): SurveyValues => {
const initial: SurveyValues = {}
for (const field of survey.fields) {
initial[field.id] = field.type === 'multi' ? [] : ''
if (field.otherFieldId) initial[field.otherFieldId] = ''
}
return initial
}
export const buildSubmissionPayload = (
survey: OnboardingSurvey,
values: SurveyValues
): Record<string, unknown> => {
const payload: Record<string, unknown> = {}
for (const field of survey.fields) {
const visible = conditionMatches(field.showWhen, values)
if (!visible) {
payload[field.id] = field.type === 'multi' ? [] : ''
continue
}
const value = values[field.id]
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
if (
field.allowOther &&
field.otherFieldId &&
value === 'other' &&
typeof otherRaw === 'string'
) {
const other = otherRaw.trim()
payload[field.id] = other || 'other'
} else {
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
}
}
return payload
}

View File

@@ -7,21 +7,11 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
@@ -35,35 +25,6 @@ const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
const mockLocalStorage = vi.hoisted(() => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
}),
__reset: () => {
store.clear()
}
}
})
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
@@ -187,20 +148,7 @@ function renderComponent() {
},
stubs: {
SelectButton: {
template: `
<div>
<button
v-for="option in options"
:key="option.value"
type="button"
@click="$emit('update:modelValue', option.value)"
>
<slot name="option" :option="option">
{{ option.label }}
</slot>
</button>
</div>
`,
template: '<div><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
@@ -219,10 +167,7 @@ describe('PricingTable', () => {
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockTrackBeginCheckout.mockReset()
mockLocalStorage.__reset()
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
@@ -272,66 +217,6 @@ describe('PricingTable', () => {
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
it('records the plan snapshot that was actually opened', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const portalOpen = createDeferredPromise<boolean>()
mockAccessBillingPortal.mockReturnValueOnce(portalOpen.promise)
renderComponent()
await flushPromises()
const creatorButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Creator'))
await userEvent.click(creatorButton!)
await flushPromises()
const monthlyToggle = screen.getByRole('button', { name: 'Monthly' })
await userEvent.click(monthlyToggle)
await flushPromises()
portalOpen.resolve(true)
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
expect(
JSON.parse(
window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
) ?? '{}'
)
).toMatchObject({
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
previous_tier: 'standard',
previous_cycle: 'monthly'
})
})
it('does not record a pending upgrade when the billing portal does not open', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
mockAccessBillingPortal.mockResolvedValueOnce(false)
renderComponent()
await flushPromises()
const creatorButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Creator'))
await userEvent.click(creatorButton!)
await flushPromises()
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
it('should use the latest userId value when it changes after mount', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'

View File

@@ -273,7 +273,6 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -451,31 +450,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
try {
if (hasPaidSubscription.value) {
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
tier: tierKey,
cycle: currentBillingCycle.value,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
...(currentTierKey.value
? { previous_tier: currentTierKey.value }
: {})
})
}
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
targetPlan.billingCycle
)
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const downgrade =
previousPlan &&
currentPlanDescriptor.value &&
isPlanDowngrade({
current: previousPlan,
current: currentPlanDescriptor.value,
target: targetPlan
})
@@ -483,20 +480,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
await accessBillingPortal(checkoutTier)
}
} else {
await performSubscriptionCheckout(

View File

@@ -145,7 +145,7 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -169,7 +169,7 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { isActiveSubscription } = useBillingContext()
const { fetchStatus, isActiveSubscription } = useBillingContext()
const isSubscriptionEnabled = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
@@ -190,10 +190,69 @@ const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
let pollInterval: number | null = null
let pollAttempts = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollAttempts = 0
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showCustomPricingTable,
(enabled) => {
if (enabled) {
window.addEventListener('focus', handleWindowFocus)
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
telemetry?.trackMonthlySubscriptionSucceeded()
emit('close', true)
}
}
@@ -204,6 +263,7 @@ const handleSubscribed = () => {
}
const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
@@ -212,6 +272,7 @@ const handleChooseTeam = () => {
}
const handleClose = () => {
stopPolling()
onClose()
}
@@ -232,6 +293,11 @@ const handleViewEnterprise = () => {
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>

View File

@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
const {
mockIsLoggedIn,
@@ -13,13 +12,10 @@ const {
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud,
mockAuthStoreInitialized,
mockLocalStorage
mockIsCloud
} = vi.hoisted(() => ({
mockIsLoggedIn: { value: false },
mockIsCloud: { value: true },
mockAuthStoreInitialized: { value: true },
mockReportError: vi.fn(),
mockAccessBillingPortal: vi.fn(),
mockShowSubscriptionRequiredDialog: vi.fn(),
@@ -32,29 +28,9 @@ const {
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionSucceeded: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
},
mockUserId: { value: 'user-123' },
mockLocalStorage: (() => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
}),
__reset: () => {
store.clear()
}
}
})()
mockUserId: { value: 'user-123' }
}))
let scope: ReturnType<typeof effectScope> | undefined
@@ -79,16 +55,6 @@ function useSubscriptionWithScope() {
return subscription
}
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: mockIsLoggedIn
@@ -143,9 +109,6 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
get isInitialized() {
return mockAuthStoreInitialized.value
},
get userId() {
return mockUserId.value
}
@@ -158,11 +121,9 @@ global.fetch = vi.fn()
describe('useSubscription', () => {
afterEach(() => {
vi.useRealTimers()
scope?.stop()
scope = undefined
setDistribution('localhost')
mockLocalStorage.__reset()
})
beforeEach(() => {
@@ -171,16 +132,11 @@ describe('useSubscription', () => {
setDistribution('cloud')
vi.clearAllMocks()
mockLocalStorage.__reset()
mockIsLoggedIn.value = false
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockUserId.value = 'user-123'
mockIsCloud.value = true
mockAuthStoreInitialized.value = true
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
@@ -333,7 +289,7 @@ describe('useSubscription', () => {
// Mock window.open
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
.mockImplementation(() => null)
const { subscribe } = useSubscriptionWithScope()
@@ -355,16 +311,6 @@ describe('useSubscription', () => {
)
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ??
'{}'
)
).toMatchObject({
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
windowOpenSpy.mockRestore()
})
@@ -381,181 +327,6 @@ describe('useSubscription', () => {
})
})
describe('pending checkout recovery', () => {
it('emits subscription_success when a pending new subscription becomes active', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-123',
started_at_ms: Date.now(),
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new'
})
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
subscription_duration: 'ANNUAL',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
user_id: 'user-123',
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD'
})
)
})
expect(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
it('emits subscription_success when a pending upgrade reaches the target tier', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-456',
started_at_ms: Date.now(),
tier: 'pro',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'creator',
previous_cycle: 'monthly'
})
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: 'attempt-456',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'creator',
value: 100
})
)
})
})
it('rechecks pending checkout attempts when the document becomes visible', async () => {
mockIsLoggedIn.value = true
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
})
vi.mocked(global.fetch).mockClear()
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-visible',
started_at_ms: Date.now(),
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
)
document.dispatchEvent(new Event('visibilitychange'))
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
it('does not clear pending attempts before auth initialization resolves', async () => {
mockAuthStoreInitialized.value = false
mockIsLoggedIn.value = false
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-pre-auth',
started_at_ms: Date.now(),
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
)
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).not.toBeNull()
})
})
it('clears pending checkout attempts when initialized while logged out', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
attempt_id: 'attempt-logout',
started_at_ms: Date.now(),
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
)
mockIsLoggedIn.value = false
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})
})
describe('requireActiveSubscription', () => {
it('should not show dialog when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
@@ -662,40 +433,6 @@ describe('useSubscription', () => {
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
it('does not start cancellation watching when the billing portal does not open', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true
mockAccessBillingPortal.mockResolvedValueOnce(false)
const activeResponse = {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_active',
renewal_date: '2025-11-16'
})
}
vi.mocked(global.fetch).mockResolvedValue(activeResponse as Response)
try {
const { fetchStatus, manageSubscription } = useSubscriptionWithScope()
await fetchStatus()
vi.mocked(global.fetch).mockClear()
await manageSubscription()
await vi.advanceTimersByTimeAsync(5000)
expect(global.fetch).not.toHaveBeenCalled()
expect(
mockTelemetry.trackMonthlySubscriptionCancelled
).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('tracks cancellation after manage subscription when status flips', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true

View File

@@ -1,10 +1,5 @@
import { computed, ref, watch } from 'vue'
import {
createSharedComposable,
defaultDocument,
defaultWindow,
useEventListener
} from '@vueuse/core'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
@@ -19,14 +14,6 @@ import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
import {
PENDING_SUBSCRIPTION_CHECKOUT_EVENT,
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
hasPendingSubscriptionCheckoutAttempt,
recordPendingSubscriptionCheckoutAttempt
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = NonNullable<
@@ -37,8 +24,6 @@ export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
const PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS = [3000, 10000, 30000]
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const telemetry = useTelemetry()
@@ -126,78 +111,6 @@ function useSubscriptionInternal() {
return getCheckoutAttribution()
}
let pendingCheckoutRecoveryTimeout: number | null = null
let pendingCheckoutRecoveryAttempt = 0
let isRecoveringPendingCheckout = false
const stopPendingCheckoutRecovery = () => {
if (pendingCheckoutRecoveryTimeout !== null && defaultWindow) {
defaultWindow.clearTimeout(pendingCheckoutRecoveryTimeout)
}
pendingCheckoutRecoveryTimeout = null
pendingCheckoutRecoveryAttempt = 0
}
const schedulePendingCheckoutRecovery = () => {
if (
!defaultWindow ||
pendingCheckoutRecoveryTimeout !== null ||
!isLoggedIn.value ||
!hasPendingSubscriptionCheckoutAttempt()
) {
return
}
const nextDelay =
PENDING_SUBSCRIPTION_CHECKOUT_RETRY_DELAYS_MS[
pendingCheckoutRecoveryAttempt
]
if (nextDelay === undefined) {
return
}
pendingCheckoutRecoveryTimeout = defaultWindow.setTimeout(() => {
pendingCheckoutRecoveryTimeout = null
pendingCheckoutRecoveryAttempt += 1
void recoverPendingSubscriptionCheckout('retry')
}, nextDelay)
}
const syncPendingSubscriptionSuccess = (
statusData: CloudSubscriptionStatusResponse
) => {
const metadata = consumePendingSubscriptionCheckoutSuccess(statusData)
if (!metadata) {
if (hasPendingSubscriptionCheckoutAttempt()) {
schedulePendingCheckoutRecovery()
} else {
stopPendingCheckoutRecovery()
}
return
}
telemetry?.trackMonthlySubscriptionSucceeded({
...(authStore.userId ? { user_id: authStore.userId } : {}),
...metadata
})
stopPendingCheckoutRecovery()
}
const buildAuthHeaders = async (): Promise<Record<string, string>> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return {
...authHeader,
'Content-Type': 'application/json'
}
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -214,24 +127,7 @@ function useSubscriptionInternal() {
)
}
const checkoutWindow = window.open(response.checkout_url, '_blank')
if (!checkoutWindow) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: 'standard',
cycle: 'monthly',
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
...(subscriptionTier.value
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
: {}),
...(subscriptionDuration.value === 'ANNUAL'
? { previous_cycle: 'yearly' as const }
: subscriptionDuration.value === 'MONTHLY'
? { previous_cycle: 'monthly' as const }
: {})
})
window.open(response.checkout_url, '_blank')
}, reportError)
const showSubscriptionDialog = (options?: {
@@ -264,11 +160,7 @@ function useSubscriptionInternal() {
})
const manageSubscription = async () => {
const didOpenPortal = await accessBillingPortal()
if (!didOpenPortal) {
return
}
await accessBillingPortal()
startCancellationWatcher()
}
@@ -292,44 +184,23 @@ function useSubscriptionInternal() {
await accessBillingPortal()
}
const recoverPendingSubscriptionCheckout = async (
source: 'bootstrap' | 'pageshow' | 'visibilitychange' | 'retry'
) => {
if (
!isCloud ||
!isLoggedIn.value ||
!hasPendingSubscriptionCheckoutAttempt() ||
isRecoveringPendingCheckout
) {
return
}
isRecoveringPendingCheckout = true
try {
await fetchSubscriptionStatus()
} catch (error) {
console.error(
`[Subscription] Failed to recover pending checkout on ${source}:`,
error
)
schedulePendingCheckoutRecovery()
} finally {
isRecoveringPendingCheckout = false
}
}
/**
* Fetch the current cloud subscription status for the authenticated user
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
)
@@ -344,55 +215,16 @@ function useSubscriptionInternal() {
const statusData = await response.json()
subscriptionStatus.value = statusData
syncPendingSubscriptionSuccess(statusData)
return statusData
}
const handlePendingSubscriptionCheckoutChange = () => {
if (!hasPendingSubscriptionCheckoutAttempt()) {
stopPendingCheckoutRecovery()
return
}
stopPendingCheckoutRecovery()
void recoverPendingSubscriptionCheckout('retry')
}
useEventListener(defaultWindow, PENDING_SUBSCRIPTION_CHECKOUT_EVENT, () => {
handlePendingSubscriptionCheckoutChange()
})
useEventListener(defaultWindow, 'storage', (event: StorageEvent) => {
if (event.key === PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) {
handlePendingSubscriptionCheckoutChange()
}
})
useEventListener(defaultWindow, 'pageshow', () => {
void recoverPendingSubscriptionCheckout('pageshow')
})
useEventListener(defaultDocument, 'visibilitychange', () => {
if (defaultDocument?.visibilityState === 'visible') {
void recoverPendingSubscriptionCheckout('visibilitychange')
}
})
watch(
() => [authStore.isInitialized, isLoggedIn.value] as const,
async ([authInitialized, loggedIn]) => {
if (!authInitialized) {
return
}
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn && isCloud) {
try {
if (hasPendingSubscriptionCheckoutAttempt()) {
await recoverPendingSubscriptionCheckout('bootstrap')
} else {
await fetchSubscriptionStatus()
}
await fetchSubscriptionStatus()
} catch (error) {
// Network errors are expected during navigation/component unmount
// and when offline - log for debugging but don't surface to user
@@ -402,8 +234,6 @@ function useSubscriptionInternal() {
}
} else {
subscriptionStatus.value = null
clearPendingSubscriptionCheckoutAttempt()
stopPendingCheckoutRecovery()
stopCancellationWatcher()
isInitialized.value = true
}
@@ -413,14 +243,20 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const headers = await buildAuthHeaders()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers,
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(checkoutAttribution)
}
)

View File

@@ -1,7 +1,7 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { components } from '@/types/comfyRegistryTypes'
export type SubscriptionTier = components['schemas']['SubscriptionTier']
type SubscriptionTier = components['schemas']['SubscriptionTier']
export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'

View File

@@ -1,306 +0,0 @@
import {
TIER_TO_KEY,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type {
SubscriptionTier,
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
'free',
'standard',
'creator',
'pro',
'founder'
])
export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
'comfy.subscription.pending_checkout_attempt'
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
is_active?: boolean
subscription_tier?: SubscriptionTier | null
subscription_duration?: SubscriptionDuration | null
}
interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
}
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
}
const dispatchPendingCheckoutChangeEvent = () => {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new Event(PENDING_SUBSCRIPTION_CHECKOUT_EVENT))
}
const createAttemptId = (): string => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `attempt-${Date.now()}`
}
const getStorage = (): Storage | null => {
let storage: Storage | null = null
try {
storage = globalThis.localStorage
} catch {
return null
}
if (
!storage ||
typeof storage.getItem !== 'function' ||
typeof storage.setItem !== 'function' ||
typeof storage.removeItem !== 'function'
) {
return null
}
return storage
}
const getAnnualCheckoutValue = (tier: Exclude<TierKey, 'free' | 'founder'>) =>
getTierPrice(tier, true) * 12
const getCheckoutValue = (tier: TierKey, cycle: BillingCycle): number => {
if (tier === 'free' || tier === 'founder') {
return getTierPrice(tier, cycle === 'yearly')
}
return cycle === 'yearly'
? getAnnualCheckoutValue(tier)
: getTierPrice(tier, false)
}
const getTierFromStatus = (
status: SubscriptionStatusSnapshot
): TierKey | null => {
const subscriptionTier = status.subscription_tier
if (!subscriptionTier) {
return null
}
return TIER_TO_KEY[subscriptionTier] ?? null
}
const getCycleFromStatus = (
status: SubscriptionStatusSnapshot
): BillingCycle | null => {
if (status.subscription_duration === 'ANNUAL') {
return 'yearly'
}
if (status.subscription_duration === 'MONTHLY') {
return 'monthly'
}
return null
}
const isExpired = (attempt: PendingSubscriptionCheckoutAttempt): boolean =>
Date.now() - attempt.started_at_ms > PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS
const normalizeAttempt = (
value: unknown
): PendingSubscriptionCheckoutAttempt | null => {
if (!value || typeof value !== 'object') {
return null
}
const candidate = value as Partial<PendingSubscriptionCheckoutAttempt>
if (
typeof candidate.attempt_id !== 'string' ||
typeof candidate.started_at_ms !== 'number' ||
typeof candidate.tier !== 'string' ||
typeof candidate.cycle !== 'string' ||
typeof candidate.checkout_type !== 'string'
) {
return null
}
if (
!VALID_TIER_KEYS.has(candidate.tier as TierKey) ||
(candidate.cycle !== 'monthly' && candidate.cycle !== 'yearly') ||
(candidate.checkout_type !== 'new' && candidate.checkout_type !== 'change')
) {
return null
}
return {
attempt_id: candidate.attempt_id,
started_at_ms: candidate.started_at_ms,
tier: candidate.tier as TierKey,
cycle: candidate.cycle,
checkout_type: candidate.checkout_type,
...(candidate.previous_tier &&
VALID_TIER_KEYS.has(candidate.previous_tier as TierKey)
? { previous_tier: candidate.previous_tier as TierKey }
: {}),
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {})
}
}
export const clearPendingSubscriptionCheckoutAttempt = (): void => {
const storage = getStorage()
if (!storage) {
return
}
try {
storage.removeItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
} catch {
return
}
dispatchPendingCheckoutChangeEvent()
}
const getPendingSubscriptionCheckoutAttempt =
(): PendingSubscriptionCheckoutAttempt | null => {
const storage = getStorage()
if (!storage) {
return null
}
let rawAttempt: string | null
try {
rawAttempt = storage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
} catch {
return null
}
if (!rawAttempt) {
return null
}
try {
const parsed = JSON.parse(rawAttempt)
const attempt = normalizeAttempt(parsed)
if (!attempt || isExpired(attempt)) {
clearPendingSubscriptionCheckoutAttempt()
return null
}
return attempt
} catch {
clearPendingSubscriptionCheckoutAttempt()
return null
}
}
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
}
if (!storage) {
return attempt
}
try {
storage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify(attempt)
)
} catch {
return attempt
}
dispatchPendingCheckoutChangeEvent()
return attempt
}
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
): boolean => {
if (!status.is_active) {
return false
}
return (
getTierFromStatus(status) === attempt.tier &&
getCycleFromStatus(status) === attempt.cycle
)
}
export const consumePendingSubscriptionCheckoutSuccess = (
status: SubscriptionStatusSnapshot
): SubscriptionSuccessMetadata | null => {
const attempt = getPendingSubscriptionCheckoutAttempt()
if (!attempt || !didAttemptSucceed(attempt, status)) {
return null
}
clearPendingSubscriptionCheckoutAttempt()
const value = getCheckoutValue(attempt.tier, attempt.cycle)
return {
checkout_attempt_id: attempt.attempt_id,
tier: attempt.tier,
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
value,
currency: 'USD',
ecommerce: {
value,
currency: 'USD',
items: [
{
item_name: attempt.tier,
item_category: 'subscription',
item_variant: attempt.cycle,
price: value,
quantity: 1
}
]
}
}
}

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from './subscriptionCheckoutUtil'
const {
@@ -9,8 +8,7 @@ const {
mockGetAuthHeader,
mockUserId,
mockIsCloud,
mockGetCheckoutAttribution,
mockLocalStorage
mockGetCheckoutAttribution
} = vi.hoisted(() => ({
mockTelemetry: {
trackBeginCheckout: vi.fn()
@@ -31,38 +29,9 @@ const {
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})),
mockLocalStorage: (() => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
}),
__reset: () => {
store.clear()
}
}
})()
}))
}))
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
@@ -112,20 +81,16 @@ describe('performSubscriptionCheckout', () => {
vi.clearAllMocks()
mockIsCloud.value = true
mockUserId.value = 'user-123'
mockLocalStorage.__reset()
})
afterEach(() => {
vi.restoreAllMocks()
setDistribution('localhost')
mockLocalStorage.__reset()
})
it('tracks begin_checkout with user id and tier metadata', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
@@ -210,9 +175,7 @@ describe('performSubscriptionCheckout', () => {
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const authHeader = createDeferred<{ Authorization: string }>()
mockUserId.value = 'user-early'
@@ -240,21 +203,4 @@ describe('performSubscriptionCheckout', () => {
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})

View File

@@ -7,7 +7,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -114,24 +113,9 @@ export async function performSubscriptionCheckout(
...checkoutAttribution
})
}
if (openInNewTab) {
const checkoutWindow = window.open(data.checkout_url, '_blank')
if (!checkoutWindow) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
window.open(data.checkout_url, '_blank')
} else {
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
globalThis.location.href = data.checkout_url
}
}

View File

@@ -3,68 +3,6 @@ import { describe, expect, it } from 'vitest'
import { KeyComboImpl } from './keyCombo'
describe('KeyComboImpl', () => {
function mockKeyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
...overrides
} as KeyboardEvent
}
describe('getKeySequences', () => {
it.each([
{
event: { key: 'Shift', shiftKey: true },
expected: ['Shift'],
label: 'Shift'
},
{
event: { key: 'Control', ctrlKey: true },
expected: ['Ctrl'],
label: 'Control'
},
{
event: { key: 'Alt', altKey: true },
expected: ['Alt'],
label: 'Alt'
},
{
event: { key: 'Meta', metaKey: true },
expected: ['Ctrl'],
label: 'Meta'
}
])(
'does not duplicate a single $label modifier press',
({ event, expected }) => {
const combo = KeyComboImpl.fromEvent(mockKeyEvent(event))
expect(combo.getKeySequences()).toEqual(expected)
expect(combo.toString()).toBe(expected.join(' + '))
}
)
it('lists held modifiers once when the pressed key is also a modifier', () => {
const combo = KeyComboImpl.fromEvent(
mockKeyEvent({ key: 'Shift', ctrlKey: true, shiftKey: true })
)
expect(combo.getKeySequences()).toEqual(['Ctrl', 'Shift'])
expect(combo.toString()).toBe('Ctrl + Shift')
})
it('keeps the primary key for non-modifier shortcuts', () => {
const combo = KeyComboImpl.fromEvent(
mockKeyEvent({ key: 'k', ctrlKey: true, shiftKey: true })
)
expect(combo.getKeySequences()).toEqual(['Ctrl', 'Shift', 'k'])
expect(combo.toString()).toBe('Ctrl + Shift + k')
})
})
describe('isBrowserReserved', () => {
it.each([
{ key: 't', ctrl: true, label: 'Ctrl + t' },

View File

@@ -3,13 +3,6 @@ import { toRaw } from 'vue'
import { RESERVED_BY_BROWSER, RESERVED_BY_TEXT_INPUT } from './reserved'
import type { KeyCombo } from './types'
const MODIFIER_KEY_LABELS: Record<string, string> = {
Control: 'Ctrl',
Meta: 'Ctrl',
Alt: 'Alt',
Shift: 'Shift'
}
export class KeyComboImpl implements KeyCombo {
key: string
ctrl: boolean
@@ -56,7 +49,7 @@ export class KeyComboImpl implements KeyCombo {
}
get isModifier(): boolean {
return this.key in MODIFIER_KEY_LABELS
return ['Control', 'Meta', 'Alt', 'Shift'].includes(this.key)
}
get modifierCount(): number {
@@ -81,37 +74,26 @@ export class KeyComboImpl implements KeyCombo {
}
getKeySequences(): string[] {
const sequences = getModifierSequences(this)
if (!this.isModifier || sequences.length === 0) {
sequences.push(getKeyLabel(this.key))
const sequences: string[] = []
if (this.ctrl) {
sequences.push('Ctrl')
}
if (this.alt) {
sequences.push('Alt')
}
if (this.shift) {
sequences.push('Shift')
}
sequences.push(this.key)
return sequences
}
}
function toNormalizedString(combo: KeyComboImpl): string {
const sequences = getModifierSequences(combo)
if (!combo.isModifier || sequences.length === 0) {
sequences.push(getKeyLabel(combo.key, true))
}
return sequences.join(' + ')
}
function getModifierSequences(combo: KeyComboImpl): string[] {
const sequences: string[] = []
if (combo.ctrl) sequences.push('Ctrl')
if (combo.alt) sequences.push('Alt')
if (combo.shift) sequences.push('Shift')
return sequences
}
function getKeyLabel(key: string, normalizeSingleCharacter = false): string {
const label = MODIFIER_KEY_LABELS[key] ?? key
return normalizeSingleCharacter && label.length === 1
? label.toLowerCase()
: label
sequences.push(combo.key.length === 1 ? combo.key.toLowerCase() : combo.key)
return sequences.join(' + ')
}

View File

@@ -23,54 +23,6 @@ type FirebaseRuntimeConfig = {
measurementId?: string
}
/**
* Server-driven onboarding survey schema.
*
* The backend ships the entire form definition so onboarding questions can
* be tweaked without a frontend release. Field types map 1:1 to a component
* in our internal UI library — see `DynamicSurveyField.vue`.
*/
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
/**
* A translatable string. Either:
* - a single literal (treated as the fallback in any locale), or
* - a locale → text map, e.g. `{ en: 'Personal use', ko: '개인 용도' }`,
* so the backend can ship translations without a frontend release.
*/
export type LocalizedString = string | Record<string, string>
export type OnboardingSurveyOption = {
value: string
label?: LocalizedString
labelKey?: string
}
export type OnboardingSurveyFieldCondition = {
field: string
equals?: string | string[]
}
export type OnboardingSurveyField = {
id: string
type: OnboardingSurveyFieldType
labelKey?: string
label?: LocalizedString
options?: OnboardingSurveyOption[]
required?: boolean
randomize?: boolean
allowOther?: boolean
otherFieldId?: string
placeholder?: string
showWhen?: OnboardingSurveyFieldCondition
}
export type OnboardingSurvey = {
version: number
introKey?: string
fields: OnboardingSurveyField[]
}
/**
* Remote configuration type
* Configuration fetched from the server at runtime
@@ -93,7 +45,6 @@ export type RemoteConfig = {
asset_rename_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
onboarding_survey?: OnboardingSurvey
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean

View File

@@ -18,7 +18,6 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryDispatcher,
@@ -81,12 +80,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.dispatch((provider) =>
provider.trackMonthlySubscriptionSucceeded?.(metadata)
)
trackMonthlySubscriptionSucceeded(): void {
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -98,90 +98,6 @@ describe('GtmTelemetryProvider', () => {
})
})
it('pushes subscription_success metadata with ecommerce reset', () => {
const provider = createInitializedProvider()
provider.trackMonthlySubscriptionSucceeded({
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD',
ecommerce: {
currency: 'USD',
value: 336,
items: [
{
item_name: 'creator',
item_category: 'subscription',
item_variant: 'yearly',
price: 336,
quantity: 1
}
]
}
})
const dataLayer = window.dataLayer as Record<string, unknown>[]
expect(dataLayer[dataLayer.length - 2]).toMatchObject({ ecommerce: null })
expect(lastDataLayerEntry()).toMatchObject({
event: 'subscription_success',
checkout_attempt_id: 'attempt-123',
value: 336
})
})
it('does not reset ecommerce when GTM is not initialized', () => {
window.__CONFIG__ = {
ga_measurement_id: 'G-TEST123'
}
const provider = new GtmTelemetryProvider()
provider.trackMonthlySubscriptionSucceeded({
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD',
ecommerce: {
currency: 'USD',
value: 336,
items: [
{
item_name: 'creator',
item_category: 'subscription',
item_variant: 'yearly',
price: 336,
quantity: 1
}
]
}
})
const dataLayer = window.dataLayer as unknown[]
expect(
dataLayer.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
'ecommerce' in (entry as Record<string, unknown>)
)
).toBe(false)
expect(
dataLayer.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as Record<string, unknown>).event === 'subscription_success'
)
).toBe(false)
})
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })

View File

@@ -16,7 +16,6 @@ import type {
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
@@ -168,17 +167,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.pushEvent('signup_opened')
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
if (this.initialized && metadata?.ecommerce) {
window.dataLayer?.push({ ecommerce: null })
}
this.pushEvent(
'subscription_success',
metadata ? { ...metadata } : undefined
)
trackMonthlySubscriptionSucceeded(): void {
this.pushEvent('subscription_success')
}
trackRunButton(options?: {

View File

@@ -31,7 +31,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -236,10 +235,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
/**

View File

@@ -26,7 +26,6 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -256,10 +255,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackMonthlySubscriptionCancelled(): void {

View File

@@ -40,11 +40,6 @@ export interface SurveyResponses {
industry?: string
useCase?: string
making?: string[]
role?: string
teamSize?: string
source?: string
usage?: string
intent?: string[]
}
export interface SurveyResponsesNormalized extends SurveyResponses {
@@ -349,32 +344,6 @@ export interface BeginCheckoutMetadata
previous_tier?: TierKey
}
interface EcommerceItemMetadata {
item_name: string
item_category: string
item_variant?: string
price: number
quantity: number
}
interface EcommerceMetadata {
currency: string
value: number
items: EcommerceItemMetadata[]
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: 'new' | 'change'
previous_tier?: TierKey
value: number
currency: string
ecommerce: EcommerceMetadata
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -391,9 +360,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
@@ -592,4 +559,3 @@ export type TelemetryEventProperties =
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata

View File

@@ -102,7 +102,6 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTelemetry } from '@/platform/telemetry'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
@@ -129,7 +128,6 @@ const { t } = useI18n()
const toast = useToast()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const telemetry = useTelemetry()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -228,7 +226,6 @@ async function handleAddCreditCard() {
if (!response) return
if (response.status === 'subscribed') {
telemetry?.trackMonthlySubscriptionSucceeded()
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
@@ -283,7 +280,6 @@ async function handleConfirmTransition() {
if (!response) return
if (response.status === 'subscribed') {
telemetry?.trackMonthlySubscriptionSucceeded()
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),

View File

@@ -11,6 +11,11 @@ function makeOutput(
}
describe(flattenNodeOutput, () => {
it('returns empty array for nullish node output', () => {
expect(flattenNodeOutput(['1', null])).toEqual([])
expect(flattenNodeOutput(['1', undefined])).toEqual([])
})
it('returns empty array for output with no known media types', () => {
const result = flattenNodeOutput(['1', makeOutput({ unknown: 'hello' })])
expect(result).toEqual([])

View File

@@ -4,7 +4,7 @@ import type { ResultItemImpl } from '@/stores/queueStore'
export function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
NodeExecutionOutput | null | undefined
]): ResultItemImpl[] {
return parseNodeOutput(nodeId, nodeOutput)
}

View File

@@ -14,7 +14,7 @@
<button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
type ViewMode = 'gallery' | 'grid'

View File

@@ -820,6 +820,17 @@ async function handleDrop(event: DragEvent) {
const node = lgraphNode.value
if (!node?.onDragDrop) return
await node.onDragDrop(event, true)
// Backport-only compat: preserve the legacy `handled === true` sync-return
// path for custom-node `onDragDrop` implementations that don't participate
// in the new `claimEvent` flag. Async handlers from `useNodeDragAndDrop`
// claim the event themselves via the second arg; sync handlers that
// return `true` still get their event claimed here so the drop does not
// bubble to the document fallback in `app.ts`.
const result = node.onDragDrop(event, true)
if (result === true && !event.defaultPrevented) {
event.preventDefault()
event.stopPropagation()
}
await result
}
</script>

View File

@@ -1,32 +1,20 @@
/* eslint-disable vue/one-component-per-file */
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import type { RenderOptions } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { NodeId as VueNodeId } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import {
createMockNodeInputSlot,
createMockNodeOutputSlot
} from '@/utils/__tests__/litegraphTestUtils'
import NodeSlots from './NodeSlots.vue'
const toVueNodeId = (id: string | number): VueNodeId => String(id)
const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
id: '123',
title: 'Test Node',
@@ -41,6 +29,22 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
...overrides
})
function makeInputSlot(
name: string,
type: string,
extra?: Partial<INodeInputSlot>
): INodeInputSlot {
return { name, type, boundingRect: [0, 0, 0, 0], link: null, ...extra }
}
function makeOutputSlot(
name: string,
type: string,
extra?: Partial<INodeOutputSlot>
): INodeOutputSlot {
return { name, type, boundingRect: [0, 0, 0, 0], links: [], ...extra }
}
// Explicit stubs to capture props for assertions
interface StubSlotData {
name?: string
@@ -51,7 +55,6 @@ interface StubSlotData {
const STUB_SLOT_PROPS = {
slotData: { type: Object as PropType<StubSlotData>, required: true },
nodeId: { type: String, required: false, default: '' },
hasError: { type: Boolean, required: false, default: false },
index: { type: Number, required: true },
readonly: { type: Boolean, required: false, default: false }
} as const
@@ -66,7 +69,6 @@ const InputSlotStub = defineComponent({
:data-name="slotData && slotData.name ? slotData.name : ''"
:data-type="slotData && slotData.type ? slotData.type : ''"
:data-node-id="nodeId"
:data-has-error="hasError ? 'true' : 'false'"
:data-readonly="readonly ? 'true' : 'false'"
/>
`
@@ -87,21 +89,6 @@ const OutputSlotStub = defineComponent({
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
type SlotComponentStubs = NonNullable<
NonNullable<RenderOptions<typeof NodeSlots>['global']>['stubs']
>
const defaultSlotStubs: SlotComponentStubs = {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
function createTrackingStub(
componentName: 'InputSlot' | 'OutputSlot',
mountCounts: Map<string, number>
@@ -125,10 +112,40 @@ function createTrackingStub(
})
}
function renderSlots(
const mountSlots = (nodeData: VueNodeData) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(NodeSlots, {
global: {
plugins: [i18n, createTestingPinia({ stubActions: false })],
stubs: {
InputSlot: InputSlotStub,
OutputSlot: OutputSlotStub
}
},
props: { nodeData }
})
}
function mountSlotsWithTracking(
nodeData: VueNodeData,
stubs: SlotComponentStubs = defaultSlotStubs
mountCounts: Map<string, number>,
trackingTarget: 'InputSlot' | 'OutputSlot'
) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const trackingStub = createTrackingStub(trackingTarget, mountCounts)
const stubs =
trackingTarget === 'InputSlot'
? { InputSlot: trackingStub, OutputSlot: OutputSlotStub }
: { InputSlot: InputSlotStub, OutputSlot: trackingStub }
return render(NodeSlots, {
global: {
plugins: [i18n, createTestingPinia({ stubActions: false })],
@@ -138,27 +155,9 @@ function renderSlots(
})
}
function renderSlotsWithTracking(
nodeData: VueNodeData,
mountCounts: Map<string, number>,
trackingTarget: 'InputSlot' | 'OutputSlot'
) {
const trackingStub = createTrackingStub(trackingTarget, mountCounts)
const stubs =
trackingTarget === 'InputSlot'
? { InputSlot: trackingStub, OutputSlot: OutputSlotStub }
: { InputSlot: InputSlotStub, OutputSlot: trackingStub }
return renderSlots(nodeData, stubs)
}
const INPUT_SLOT_SELECTOR = '.stub-input-slot'
const OUTPUT_SLOT_SELECTOR = '.stub-output-slot'
afterEach(() => {
vi.restoreAllMocks()
})
function querySlotElements(
container: Element,
selector: string
@@ -171,42 +170,25 @@ function querySlotElements(
}
function getRenderedSlotIndex(container: Element, slotName: string) {
return Number(getRenderedSlotElement(container, slotName).dataset.index)
}
function getRenderedSlotElement(container: Element, slotName: string) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector(`[data-name="${slotName}"]`)
if (!(el instanceof HTMLElement)) {
throw new Error(`Slot element "${slotName}" not found`)
}
return el
}
function expectSlotError(
container: Element,
slotName: string,
hasError: boolean
) {
expect(getRenderedSlotElement(container, slotName)).toHaveAttribute(
'data-has-error',
hasError ? 'true' : 'false'
)
return Number(el.dataset.index)
}
describe('NodeSlots.vue', () => {
it('filters out inputs with widget property and maps indexes correctly', () => {
const inputs = [
createMockNodeInputSlot({ name: 'objNoWidget', type: 'number' }),
createMockNodeInputSlot({
name: 'objWithWidget',
type: 'number',
const inputs: INodeInputSlot[] = [
makeInputSlot('objNoWidget', 'number'),
makeInputSlot('objWithWidget', 'number', {
widget: { name: 'objWithWidget' }
}),
createMockNodeInputSlot({ name: 'stringInput', type: 'string' })
makeInputSlot('stringInput', 'string')
]
const { container } = renderSlots(makeNodeData({ inputs }))
const { container } = mountSlots(makeNodeData({ inputs }))
const inputEls = querySlotElements(container, INPUT_SLOT_SELECTOR)
expect(inputEls).toHaveLength(2)
@@ -240,12 +222,12 @@ describe('NodeSlots.vue', () => {
})
it('maps outputs and passes correct indexes', () => {
const outputs = [
createMockNodeOutputSlot({ name: 'outA', type: 'any' }),
createMockNodeOutputSlot({ name: 'outB', type: 'any' })
const outputs: INodeOutputSlot[] = [
makeOutputSlot('outA', 'any'),
makeOutputSlot('outB', 'any')
]
const { container } = renderSlots(makeNodeData({ outputs }))
const { container } = mountSlots(makeNodeData({ outputs }))
const outputEls = querySlotElements(container, OUTPUT_SLOT_SELECTOR)
expect(outputEls).toHaveLength(2)
@@ -262,100 +244,15 @@ describe('NodeSlots.vue', () => {
])
})
it('passes validation error state to matching input slots', async () => {
const inputs = [
createMockNodeInputSlot({ name: 'model', type: 'MODEL' }),
createMockNodeInputSlot({ name: 'steps', type: 'INT' })
]
const nodeData = makeNodeData({ inputs })
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
nodeData.id,
'model'
)
await nextTick()
expectSlotError(container, 'model', true)
expectSlotError(container, 'steps', false)
})
it('maps one-level subgraph execution ids to input slot errors', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('InteriorNode')
interiorNode.id = 70
interiorNode.addInput('model', 'MODEL')
interiorNode.addInput('steps', 'INT')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const graph = subgraphNode.rootGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const nodeData = makeNodeData({
id: toVueNodeId(interiorNode.id),
subgraphId: subgraph.id,
inputs: interiorNode.inputs
})
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
'65:70',
'model'
)
await nextTick()
expectSlotError(container, 'model', true)
expectSlotError(container, 'steps', false)
})
it('maps nested subgraph execution ids to input slot errors', async () => {
const innerSubgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
innerNode.id = 63
innerNode.addInput('image', 'IMAGE')
innerNode.addInput('mask', 'MASK')
innerSubgraph.add(innerNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 70,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 65 })
const graph = outerSubgraphNode.rootGraph
graph.add(outerSubgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const nodeData = makeNodeData({
id: toVueNodeId(innerNode.id),
subgraphId: innerSubgraph.id,
inputs: innerNode.inputs
})
const { container } = renderSlots(nodeData)
seedRequiredInputMissingNodeError(
useExecutionErrorStore(),
'65:70:63',
'mask'
)
await nextTick()
expectSlotError(container, 'image', false)
expectSlotError(container, 'mask', true)
})
it('remounts OutputSlot when index shifts due to output removal', async () => {
const mountCounts = new Map<string, number>()
const outputs = [
createMockNodeOutputSlot({ name: 'outA', type: 'IMAGE' }),
createMockNodeOutputSlot({ name: 'outB', type: 'VIDEO' }),
createMockNodeOutputSlot({ name: 'outC', type: 'AUDIO' })
makeOutputSlot('outA', 'IMAGE'),
makeOutputSlot('outB', 'VIDEO'),
makeOutputSlot('outC', 'AUDIO')
]
const { container, rerender } = renderSlotsWithTracking(
const { container, rerender } = mountSlotsWithTracking(
makeNodeData({ outputs }),
mountCounts,
'OutputSlot'
@@ -367,8 +264,8 @@ describe('NodeSlots.vue', () => {
await rerender({
nodeData: makeNodeData({
outputs: [
createMockNodeOutputSlot({ name: 'outA', type: 'IMAGE' }),
createMockNodeOutputSlot({ name: 'outC', type: 'AUDIO' })
makeOutputSlot('outA', 'IMAGE'),
makeOutputSlot('outC', 'AUDIO')
]
})
})
@@ -378,21 +275,21 @@ describe('NodeSlots.vue', () => {
})
it('renders nothing when there are no inputs/outputs', () => {
const { container } = renderSlots(makeNodeData({ inputs: [], outputs: [] }))
const { container } = mountSlots(makeNodeData({ inputs: [], outputs: [] }))
expect(querySlotElements(container, INPUT_SLOT_SELECTOR)).toHaveLength(0)
expect(querySlotElements(container, OUTPUT_SLOT_SELECTOR)).toHaveLength(0)
})
it('passes correct actual indices for multi-group input layout', () => {
const inputs = [
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_images.img1', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_images.img2', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' }),
createMockNodeInputSlot({ name: 'ref_videos.vid1', type: 'VIDEO' })
const inputs: INodeInputSlot[] = [
makeInputSlot('ref_images.img0', 'IMAGE'),
makeInputSlot('ref_images.img1', 'IMAGE'),
makeInputSlot('ref_images.img2', 'IMAGE'),
makeInputSlot('ref_videos.vid0', 'VIDEO'),
makeInputSlot('ref_videos.vid1', 'VIDEO')
]
const { container } = renderSlots(makeNodeData({ inputs }))
const { container } = mountSlots(makeNodeData({ inputs }))
const inputEls = querySlotElements(container, INPUT_SLOT_SELECTOR)
@@ -414,11 +311,11 @@ describe('NodeSlots.vue', () => {
it('remounts InputSlot when index shifts due to autogrow insertion', async () => {
const mountCounts = new Map<string, number>()
const initialInputs = [
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' })
makeInputSlot('ref_images.img0', 'IMAGE'),
makeInputSlot('ref_videos.vid0', 'VIDEO')
]
const { container, rerender } = renderSlotsWithTracking(
const { container, rerender } = mountSlotsWithTracking(
makeNodeData({ inputs: initialInputs }),
mountCounts,
'InputSlot'
@@ -430,9 +327,9 @@ describe('NodeSlots.vue', () => {
await rerender({
nodeData: makeNodeData({
inputs: [
createMockNodeInputSlot({ name: 'ref_images.img0', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_images.img1', type: 'IMAGE' }),
createMockNodeInputSlot({ name: 'ref_videos.vid0', type: 'VIDEO' })
makeInputSlot('ref_images.img0', 'IMAGE'),
makeInputSlot('ref_images.img1', 'IMAGE'),
makeInputSlot('ref_videos.vid0', 'VIDEO')
]
})
})

View File

@@ -13,7 +13,6 @@
:slot-data="input"
:node-type="nodeData?.type || ''"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:has-error="inputHasError(input)"
:index="getActualInputIndex(input, index)"
/>
</div>
@@ -45,8 +44,6 @@ import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { getLocatorIdFromNodeData } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
@@ -58,8 +55,6 @@ interface NodeSlotsProps {
}
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
const executionErrorStore = useExecutionErrorStore()
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const linkedWidgetInputs = computed(() =>
unified ? linkedWidgetedInputs(nodeData) : []
@@ -70,10 +65,6 @@ const filteredInputs = computed(() => [
...linkedWidgetInputs.value
])
function inputHasError(input: INodeSlot): boolean {
return executionErrorStore.slotHasError(nodeLocatorId.value, input.name)
}
const unifiedWrapperClass = computed((): string =>
cn(
unified &&

View File

@@ -19,7 +19,12 @@
v-if="activeItem"
:src="getItemSrc(activeItem)"
:alt="getItemAlt(activeItem, activeIndex)"
class="h-auto w-full rounded-sm object-contain"
:class="
cn(
'h-auto w-full rounded-sm object-contain transition-opacity',
showControls && 'opacity-50'
)
"
@load="handleImageLoad"
/>
@@ -233,7 +238,7 @@ const showNavButtons = computed(
)
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
const toggleButtonClass = actionButtonClass

View File

@@ -28,7 +28,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
const mockAssetsStoreState = vi.hoisted(() => {
const inputAssets: AssetItem[] = []
return {
@@ -56,8 +55,7 @@ vi.mock('@/stores/assetsStore', () => ({
return mockAssetsStoreState.inputLoading
},
updateInputs: mockUpdateInputs,
getInputName: mockGetInputName,
getAssets: mockGetAssets
getInputName: mockGetInputName
}))
}))
@@ -201,117 +199,67 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
describe('cloud asset browser widget', () => {
// "Select model" is the fallback from t('widgets.selectModel')
// in createAssetWidget when defaultValue is undefined.
const PLACEHOLDER = 'Select model'
it('should create asset browser widget when API enabled', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
function setupCloudAssetWidget(
inputSpecOverrides: Partial<InputSpec> = {}
) {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: ''
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
...inputSpecOverrides
})
constructor(mockNode, inputSpec)
return { mockNode }
}
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
}
it('should create asset browser widget when API enabled', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
options: ['model1.safetensors', 'model2.safetensors']
})
expect(
vi.mocked(assetService.shouldUseAssetBrowser)
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
expect.anything(),
expect.any(Function),
expect.any(Object)
)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
it('should use first cloud asset as default instead of server combo options', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const widget = constructor(mockNode, inputSpec)
const { mockNode } = setupCloudAssetWidget({
options: ['local_only_model.safetensors']
})
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
expect(widget).toBe(mockWidget)
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
it('should create asset browser widget when default value provided without options', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
})
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const widget = constructor(mockNode, inputSpec)
const { mockNode } = setupCloudAssetWidget({
default: 'not_in_cloud.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
})
it('should prefer inputSpec.default when it exists in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'other_model.safetensors' }),
createMockAssetItem({ name: 'fallback.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
})
it('should create asset browser widget when default value provided without options', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
it('should fallback to placeholder when cloud assets not loaded', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
options: ['local_model.safetensors']
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(widget).toBe(mockWidget)
})
it('should show Select model when asset widget has undefined current value', () => {

View File

@@ -6,7 +6,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import type {
@@ -105,25 +104,6 @@ const addMultiSelectWidget = (
return widget
}
/**
* Resolve the default value for a cloud asset widget.
* Priority: inputSpec.default (if present in cloud assets) → first cloud
* asset → undefined (shows placeholder).
*/
function resolveCloudDefault(
nodeType: string,
specDefault: string | undefined
): string | undefined {
const assets = useAssetsStore().getAssets(nodeType)
if (specDefault != null) {
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
if (inAssets) return specDefault
}
// empty filename → undefined (shows placeholder)
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
return filename || undefined
}
function createAssetBrowserWidget(
node: LGraphNode,
inputSpec: ComboInputSpec,
@@ -215,14 +195,7 @@ const addComboWidget = (
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
return createAssetBrowserWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {

View File

@@ -323,38 +323,6 @@ describe('useGLSLPreview', () => {
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
})
it('uses custom resolution when size_mode is custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'custom' })
store._widgetMap.set('size_mode.width', { value: 800 })
store._widgetMap.set('size_mode.height', { value: 600 })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
store._widgetMap.delete('size_mode')
store._widgetMap.delete('size_mode.width')
store._widgetMap.delete('size_mode.height')
})
it('uses default resolution when size_mode is not custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'from_input' })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
store._widgetMap.delete('size_mode')
})
it('disposes renderer and cancels debounce on cleanup', async () => {
const node = createMockNode()
const { dispose } = await setupAndRender(node)

View File

@@ -282,44 +282,7 @@ function createInnerPreview(
}
}
const customResolution = computed((): [number, number] | null => {
const gId = graphId.value
if (!gId) return null
const sizeModeNodeId = innerGLSLNode
? (innerGLSLNode.id as NodeId)
: nodeId.value
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
)
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
)
if (!widthWidget || !heightWidget) return null
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
})
function getResolution(): [number, number] {
const custom = customResolution.value
if (custom) return custom
const node = nodeRef.value
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
@@ -362,6 +325,27 @@ function createInnerPreview(
}
}
const gId = graphId.value
const nId = nodeId.value
if (gId && nId != null) {
const widthWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.height'
)
if (widthWidget && heightWidget) {
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}
@@ -481,8 +465,7 @@ function createInnerPreview(
floatValues.value,
intValues.value,
boolValues.value,
curveValues.value,
customResolution.value
curveValues.value
] as const,
() => {
if (shouldRender.value) debouncedRender()

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