Compare commits

..

32 Commits

Author SHA1 Message Date
jaeone94
ebe0538c43 fix: backport shared workflow asset import timing
Backport #12333 to core/1.44.

Resolve the templates.spec conflict and adapt the browser regression test to the 1.44 ComfyPage fixture API.
2026-05-20 13:30:36 +09:00
Dante
08dcc96aa3 [backport core/1.44] fix: stabilize multi-output expansion + simplify cloud output fetch (FE-227) (#12006) (#12352)
Backport of #12006 to core/1.44.

## Conflict resolution

One conflict in
`src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
(imports + dropdown source). On main, the original PR replaced
`useMediaAssets('output')` with `isCloud ? useFlatOutputAssets() :
useAssetsApi('output')`. On `core/1.44`, the local path still goes
through `useMediaAssets`, which itself internally gates `isCloud →
useAssetsApi : useInternalFilesApi`. Resolution preserves the new cloud
branch while keeping `useMediaAssets('output')` for the local path on
this branch:

```ts
const outputMediaAssets = isCloud
  ? useFlatOutputAssets()
  : useMediaAssets('output')
```

All other files auto-merged.

## Verification

- `pnpm typecheck` 
- `pnpm test:unit` — `assetsStore.test.ts` (60) +
`useWidgetSelectItems.test.ts` (40) = 100/100 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12352-backport-core-1-44-fix-stabilize-multi-output-expansion-simplify-cloud-output-fetch-3666d73d3650812986a7c8787a225ee3)
by [Unito](https://www.unito.io)
2026-05-20 12:01:30 +09:00
Comfy Org PR Bot
0a75aca0f3 [backport core/1.44] fix: include share_id when importing published assets (FE-603) (#12255)
Backport of #12055 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12255-backport-core-1-44-fix-include-share_id-when-importing-published-assets-FE-603-3606d73d36508157afc4c5a481ccb290)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 22:22:59 +09:00
Comfy Org PR Bot
47bbb659e6 [backport core/1.44] fix: stop trackpad pinch/swipe gestures from breaking the UI (#12290)
Backport of #12052 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12290-backport-core-1-44-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3616d73d365081d697c9e8fb9b0d489d)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-19 22:22:52 +09:00
Comfy Org PR Bot
b059c22def [backport core/1.44] fix: keep node context menu overflow visible when content fits (#12337)
Backport of #12035 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12337-backport-core-1-44-fix-keep-node-context-menu-overflow-visible-when-content-fits-3656d73d36508101b0d3ee3d2f699804)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-19 22:22:02 +09:00
Comfy Org PR Bot
2806fab735 [backport core/1.44] Fix descriptions on core blueprints (#12258)
Backport of #12220 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12258-backport-core-1-44-Fix-descriptions-on-core-blueprints-3606d73d3650812483eef5010be7580a)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-13 23:51:13 -07:00
Comfy Org PR Bot
1ef579abf4 [backport core/1.44] fix: open node info panel from context menu (#12247)
Backport of #12205 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12247-backport-core-1-44-fix-open-node-info-panel-from-context-menu-3606d73d365081c1af40fe414729f9d2)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 12:25:20 +09:00
Comfy Org PR Bot
9530605c3b [backport core/1.44] fix: clear media upload errors via widget change (#12244)
Backport of #12212 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12244-backport-core-1-44-fix-clear-media-upload-errors-via-widget-change-3606d73d365081f09e28cef419b2a65d)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-14 12:19:47 +09:00
Comfy Org PR Bot
e6ead5631a [backport core/1.44] fix: Load Image preview retains deleted asset (FE-230) (#12129)
Backport of #11493 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12129-backport-core-1-44-fix-Load-Image-preview-retains-deleted-asset-FE-230-35d6d73d36508157ada3db9f8416e113)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-14 11:21:29 +09:00
Comfy Org PR Bot
a9b9de2b10 1.44.19 (#12147)
Patch version increment to 1.44.19

**Base branch:** `core/1.44`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12147-1-44-19-35d6d73d365081489f37e84e53576acc)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-05-14 11:20:34 +09:00
jaeone94
9be62a1845 [backport core/1.44] fix: suppress missing media scan during uploads (#12111) (#12188)
## Summary

Manual backport of #12111 to `core/1.44`.

This suppresses false-positive missing media detection while media
loader nodes are still uploading files from drag/drop, paste, or
file-select flows.

## Conflict Resolution

The cherry-pick conflicted only in
`src/platform/missingMedia/missingMediaScan.test.ts` because the target
branch still has the older annotated-media parameterized test block
around the insertion point. I resolved it by:

- adding the new upload-state tests from #12111 above the existing
annotated-media cases
- keeping the existing release-branch annotated-media `it.each` cases
intact
- using `it.for([false, true])` only for the new upload-state test added
by #12111

## Validation

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
src/platform/missingMedia/missingMediaScan.test.ts
src/composables/node/useNodeImageUpload.test.ts
src/extensions/core/uploadAudio.test.ts
src/composables/graph/useErrorClearingHooks.test.ts`

Result: 4 files passed, 87 tests passed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12188-backport-core-1-44-fix-suppress-missing-media-scan-during-uploads-12111-35e6d73d3650812dab83ccf51411cc88)
by [Unito](https://www.unito.io)
2026-05-12 20:37:47 +09:00
Dante
d4d2089663 [backport core/1.44] fix(i18n): clamp unsupported browser locales to a shipped tag (#11712) (#12178)
*PR Created by the Glary-Bot Agent*

---

Backport of #11712 to `core/1.44`.

## Summary

Cherry-picks `ceb993605` ("fix(i18n): clamp unsupported browser locales
to a shipped tag") onto `core/1.44`.

Sidebar buttons rendered literal i18n keys (e.g.
`sideToolbar.labels.assets`) on a fresh install when the user's
`navigator.language` base tag wasn't one of the 12 shipped locales —
German/Italian/Polish/Dutch/Brazilian-Portuguese users among others.

## Cherry-pick conflict resolution

One file required manual resolution:

- **`browser_tests/tests/customNodeLocales.spec.ts`** — `modify/delete`
conflict. This file does not exist on `core/1.44` (it was introduced on
`main` after the 1.44 branch cut, in #12132). Dropped the modification
from the backport; the underlying test file is not on the release
branch, so its updates are irrelevant here.

All other files merged cleanly (`src/views/GraphView.vue` had an
auto-merge that resolved without intervention).

## Verification

- `pnpm typecheck` — clean
- `pnpm test:unit src/i18n.test.ts` — **17/17 passing** (covers the new
`resolveSupportedLocale` block and the updated unsupported-locale clamp
behaviour)
- `eslint` on all changed files — clean
- **Manual verification via Playwright on the backport branch**,
simulating a fresh install with `navigator.language='de-DE'`:
- Sidebar `aria-label`s render real strings ("Assets (a)", "Node Library
(n)", "Workflows (w)", "Settings", …) — **no** literal i18n keys like
`sideToolbar.labels.assets`.
- Confirmed `hasLiteralKeys: false` on the rendered DOM, matching the
fixed behaviour from the original PR.
  - Screenshot attached.

## Files

```
 apps/desktop-ui/src/i18n.ts                     |   3 +-
 browser_tests/tests/i18nLocaleFallback.spec.ts  |  47 ++++++++
 browser_tests/tests/templates.spec.ts           |  59 +++++-----
 src/i18n.test.ts                                | 109 +++++++++++++++---
 src/i18n.ts                                     | 145 ++++++++----------------
 src/locales/CONTRIBUTING.md                     |  44 +------
 src/locales/localeConfig.ts                     |  82 ++++++++++++++
 src/platform/settings/constants/coreSettings.ts |  21 +---
 src/views/GraphView.vue                         |  20 ++--
```

Backport-of: #11712
Fixes #10563 on the 1.44 release line


## Screenshots

![Core 1.44 backport — sidebar with navigator.language=de-DE shows real
labels (Assets, Node Library, Workflows...) instead of literal i18n
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/08da2130835cfe011512360b413c21eae085ac145ae94852e9dc841da09d0411/pr-images/1778566788268-adcce911-6973-40c7-8c29-7fb8941587f4.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12178-backport-core-1-44-fix-i18n-clamp-unsupported-browser-locales-to-a-shipped-tag-117-35e6d73d3650817dae90d9ae68c7f0c6)
by [Unito](https://www.unito.io)
2026-05-12 18:35:38 +09:00
Comfy Org PR Bot
7aaade0f68 [backport core/1.44] fix: handle annotated output media paths in missing media scan (#12121)
Backport of #12069 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12121-backport-core-1-44-fix-handle-annotated-output-media-paths-in-missing-media-scan-35d6d73d3650818db83ff9fbbe417f8d)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-11 01:35:02 +00:00
Comfy Org PR Bot
e45e249ed9 [backport core/1.44] fix: make credits help icon a tooltip button in cloud user popover (FE-617) (#12083)
Backport of #12072 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12083-backport-core-1-44-fix-make-credits-help-icon-a-tooltip-button-in-cloud-user-popover--35a6d73d365081b1a23bd0d78cedcab6)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-05-08 23:20:10 +09:00
Comfy Org PR Bot
5878840f26 [backport core/1.44] fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12080)
Backport of #12051 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12080-backport-core-1-44-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragged-35a6d73d36508163a660ffec1cb4fa8c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-05-08 20:55:05 +09:00
Comfy Org PR Bot
128ca823fd [backport core/1.44] fix: remove asset hash verification (#12078)
Backport of #12061 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12078-backport-core-1-44-fix-remove-asset-hash-verification-35a6d73d3650819e91fcf10e6e353080)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-08 09:12:15 +00:00
Comfy Org PR Bot
97a80cad22 [backport core/1.44] refactor: align asset pagination schema (#12064)
Backport of #11899 to `core/1.44`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12064-backport-core-1-44-refactor-align-asset-pagination-schema-3596d73d365081728e3eee2e852b10ce)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-07 17:36:39 +00:00
jaeone94
1ab9752af8 fix: keep Reka overlays above PrimeVue dialogs (#12038)
## Summary

Temporarily patch FE-569 by keeping the affected portaled Reka dropdowns
and menus above their containing PrimeVue dialogs when PrimeVue auto
z-index state has been elevated.

## Changes

- **What**: Added a small compatibility helper,
`usePrimeVueOverlayChildStyle`, that returns an anchor ref plus a
computed inline style for child popover content. The helper finds the
nearest PrimeVue dialog mask (`.p-dialog-mask` / `.p-overlay-mask`) from
the parent surface and, only when found, applies `parent z-index + 1` to
the affected Reka overlay content.
- **What**: Applied that helper at the exact PrimeVue parent surfaces
where the issue was found. This PR does not add a global overlay policy
and does not change every Reka select/dropdown in the app.
- **What**: Added optional `contentStyle`/`selectContentStyle` plumbing
only where needed so the style reaches the actual portaled Reka overlay
root.
- **What**: Added focused unit coverage for the helper contract: no
PrimeVue parent preserves existing stacking, PrimeVue dialog/overlay
masks render child content above the parent, low parent z-index values
respect the Reka floor, and invalid z-index values do not inject an
inline override.
- **Approach**: This is intentionally a minimal, parent-scoped band-aid.
It avoids a global PrimeVue overlay scanner because global sampling can
be polluted by unrelated persistent PrimeVue roots such as Toast and
would turn this fix into a broader layering policy.
- **Approach**: The patch targets the confirmed failure mode: a Reka
child overlay rendering below its owning PrimeVue dialog after PrimeVue
autoZIndex has been elevated. It does not attempt to solve PrimeVue
z-index globally.
- **Lifecycle**: This is temporary migration compatibility. PrimeVue
dialogs and controls are being incrementally migrated to Reka UI, so
`usePrimeVueOverlayChildStyle` and the optional style props added for
FE-569 should be removed once the affected parent surfaces move to Reka.
- **Breaking**: None. New props are optional and no public API contract
is changed.
- **Dependencies**: None.

## Patched Entry Points

This PR pinpoints the six affected user-facing surfaces below. Each
patch is applied from the PrimeVue dialog parent and passed only to the
Reka child overlay content that can render underneath that parent.


https://github.com/user-attachments/assets/d0d1522a-ffc7-4934-9e7a-06b83e20f809

1. **Workflow Template Library filters**
- **How to enter**: click the Templates button in the left sidebar, or
open the Comfy menu and choose **Browse Templates**.
- **Affected elements**: the template filter popovers in
`WorkflowTemplateSelectorDialog`: **Model**, **Use case**, **Runs on**,
and **Sort by**.
- **Patch point**: `WorkflowTemplateSelectorDialog.vue` anchors to the
template dialog content filter area and passes `selectContentStyle` to
the affected `MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/3641fa24-da51-4392-a904-9085f8a5a2f4

2. **Manager dialog header controls**
- **How to enter**: open Manager from the top/menu Manager entry when
the new Manager UI is available.
- **Affected elements**: the Manager header controls in `ManagerDialog`:
search mode `SingleSelect`, search autocomplete suggestions, and
**Sort** `SingleSelect`.
- **Patch point**: `ManagerDialog.vue` anchors to the dialog header and
passes `selectContentStyle` to those three Reka overlays.


https://github.com/user-attachments/assets/cf25cc06-f851-48ef-9d9c-9ec2da8afc06

3. **Asset Browser filter bar**
- **How to enter**: open the Asset Browser from an eligible model widget
browse action, the Model Library flow, or another
`useAssetBrowserDialog` caller.
- **Affected elements**: `AssetFilterBar` controls: **File formats**,
**Base models**, **Ownership**, and **Sort by**.
- **Patch point**: `AssetBrowserModal.vue` anchors to the PrimeVue
dialog header and passes the style through `AssetFilterBar` to its
`MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/e27bd805-10c0-4b3b-97f3-9e11faa47021

4. **Asset Browser model info panel**
- **How to enter**: open Asset Browser, select an asset, then use the
right-side model info panel.
- **Affected element**: the **Model type** select in `ModelInfoPanel`.
- **Patch point**: `AssetBrowserModal.vue` reuses the same parent-scoped
style and passes it to `ModelInfoPanel` as `selectContentStyle`.


https://github.com/user-attachments/assets/5e9f7ef0-ebd7-4987-ba1b-2137c034086f

5. **Upload Model confirmation step**
- **How to enter**: open Asset Browser, click **Upload**, enter/fetch
model metadata, then proceed to the confirmation step.
- **Affected element**: the **Model type** `SingleSelect` in
`UploadModelConfirmation`.
- **Patch point**: `UploadModelConfirmation.vue` anchors within the
upload dialog content and passes `selectContentStyle` to the model type
selector.



https://github.com/user-attachments/assets/ec145f26-8621-455b-915e-bedee47e1cbd

6. **Settings > Keybinding panel controls**
- **How to enter**: open Settings from the sidebar/menu, then select the
**Keybinding** panel.
- **Affected elements**: the keybinding preset select, the preset
overflow dropdown menu, and the row context menu inside
`KeybindingPanel`.
- **Patch point**: `KeybindingPanel.vue` anchors to the settings dialog
panel and passes `keybindingOverlayContentStyle` only to those Reka
overlay roots.

## Review Focus

- Confirm the patch stays narrowly scoped to the six known PrimeVue
parent + Reka child overlay surfaces above.
- Confirm `contentStyle` reaches the actual portaled Reka overlay
content in each patched path.
- Confirm the fallback behavior preserves existing stacking when no
PrimeVue parent overlay is found; in that case the helper returns an
empty style object and leaves existing Tailwind z-index classes alone.
- Please avoid expanding this into a larger overlay refactor. The goal
is a clean, backport-friendly compatibility patch while the Reka
migration continues.

Validation performed:

- `pnpm exec vitest run src/composables/usePopoverSizing.test.ts`
- `pnpm typecheck`
- `pnpm lint` (passes with existing unrelated warnings only)
- `pnpm format:check`
- commit hook lint-staged checks (`oxfmt`, `stylelint`, `oxlint`,
`eslint --fix`, `pnpm typecheck`)
- pre-push `pnpm knip`

Linear: FE-569

## Bug Screenshots 



https://github.com/user-attachments/assets/e73761af-9867-4c50-ab0d-4e32e59011e1



https://github.com/user-attachments/assets/145daf4d-3268-428b-9987-1e1afd0b866f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12038-fix-keep-Reka-overlays-above-PrimeVue-dialogs-3596d73d365081e7af49dbc4d3905962)
by [Unito](https://www.unito.io)
2026-05-07 13:37:08 +09:00
Dante
e469611f6d perf: memoize asset display transform across filter tab switches (#11491)
## Root cause

`useAssetBrowser`'s `filteredAssets` computed re-ran
`.map(transformAssetForDisplay)` over the full result set on every tab
switch. `transformAssetForDisplay` allocates fresh `badges`/`stats`
objects, walks `tags`, calls `getAssetBaseModels`, and runs i18n date
formatting per asset — none of which were memoized. Switching All /
Inputs / Outputs forced N transforms per click and produced brand-new
`AssetDisplayItem` references, which also defeated `:key`-based diffing
in `AssetGrid` / `VirtualGrid` and re-rendered every visible card.

## Fix

Memoize `transformAssetForDisplay` at module scope with a
`WeakMap<AssetItem, AssetDisplayItem>`. Unchanged assets reuse the same
display item across tab switches; the GC reclaims entries when assets
are released.

## Before / after (n=200 assets, 6 tab switches: inputs → outputs → all
→ inputs → outputs → all)

| Metric                          | Before | After |
| ------------------------------- | -----: | ----: |
| `transformAssetForDisplay` runs |    800 |     0 |
| Wall time (Vitest harness)      | 13.2 ms | 8.1 ms |
| Reused `AssetDisplayItem` refs  |      0 |   200 |

Measured via
`src/platform/assets/composables/useAssetBrowser.perf.test.ts` plus a
temporary `process.stderr.write` harness.

## Red / green

| Commit    | Purpose |
| --------- | ------- |
| 7367fdd60 | test: failing perf assertions (transform budget + reused
refs) |
| 021b98ac0 | perf: WeakMap memoization of `transformAssetForDisplay` |

## Test plan

- [x] `pnpm exec vitest run
src/platform/assets/composables/useAssetBrowser` — 42/42 pass (including
2 new perf assertions)
- [x] `pnpm typecheck`
- [x] `pnpm exec eslint` on touched files
- [x] `pnpm exec oxfmt --check` on touched files

Fixes [FE-229

](https://linear.app/comfyorg/issue/FE-229/asset-browser-switching-all-inputs-outputs-tabs-is-slow)Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11491-perf-memoize-asset-display-transform-across-filter-tab-switches-3496d73d36508112822dd6e7b58040fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-07 13:03:57 +09:00
Christian Byrne
ad6cbf7cbe feat: align cloud batch count limit with server-side queue cap (#11876)
*PR Created by the Glary-Bot Agent*

---

Raises `Comfy.QueueButton.BatchCountLimit` on cloud from `32` to `100`
to match the server-side `MaxQueuedJobsPerUser` cap
(`cloud/infrastructure/dynamicconfig/prod/config.json:3`). The desktop
default was already `100` and is unchanged — collapsing both branches to
the same constant.

Addresses Discord feature request: [Increase queue batch limit from
200](https://discord.com/channels/1218270712402415686/1243609826299220039/1499104231381012641).

## Change

```diff
-    defaultValue: isCloud ? 32 : 100,
+    defaultValue: 100,
```

The setting is read dynamically by all batch count UIs
(`BatchCountEdit.vue`, `LinearControls.vue`).

## Why 100 (not 512)

Original ask was 200→512. Investigation showed:

- The actual previous default was `100` (desktop) / `32` (cloud), not
200.
- Cloud enforces `MaxQueuedJobsPerUser = 100` per workspace server-side.
A higher frontend cap can't unlock more queued work — extra prompts just
get rejected with `QUEUE_LIMIT`.
- Frontend submits prompts as N sequential `POST /prompt` calls (no
batched-prompt endpoint), so the UI cap is purely about how many clicks
it takes — not throughput.
- Going from 32 → 100 lets cloud users match the server cap in one click
instead of 4. No new behavior is unlocked.

## Known limitation (pre-existing, not introduced here)

The new max equals the absolute server cap, not the user's remaining
capacity. A user with already-queued work can hit `QUEUE_LIMIT`
mid-batch. The pre-existing 32 limit had the same shape (just at a
smaller scale); deriving the UI max from `cap - outstanding` would
require polling and reactive state and is out of scope for a one-line
setting bump.

## Verification

- `pnpm typecheck` — passes
- `pnpm lint` — 0 errors (1 pre-existing warning in unrelated test file)
- `pnpm test:unit` — `BatchCountEdit.test.ts` (3 tests) +
`src/platform/settings/**` (70 tests) all pass
- **Manual (Playwright)**:
- `settingStore.get('Comfy.QueueButton.BatchCountLimit')` returns `100`
at runtime
  - Typing `999` into the batch count widget clamps to `100`
  - Increment button is disabled at `100` (max reached)

## Screenshots

![Queue batch count widget set to 100 (the new max). Increment button is
disabled because the limit was
reached.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6a01e1ea573fa88f163fb64768a619d250a0b5da26b04249929b8734e04dac57/pr-images/1777863864881-dcc0e33d-e5e7-4a12-9cf3-89af60fd12f6.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11876-feat-align-cloud-batch-count-limit-with-server-side-queue-cap-3566d73d3650819b8d01dbf83d1a8e49)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-06 23:19:32 +00:00
Kelly Yang
5ebf5e03ae refactor(load3d): replace PrimeVue Select/Slider/Checkbox with Reka UI (#12020)
Replace PrimeVue components in 3D node viewer controls with the
project's Reka UI equivalents across 7 files.

## Changes

| File | Replaced |
|------|---------|
| `AnimationControls.vue` | `Select` × 2 (speed + animation) |
| `ViewerModelControls.vue` | `Select` × 2 (up direction + material
mode) |
| `ViewerCameraControls.vue` | `Select` + `Slider` (camera type + FOV) |
| `ViewerExportControls.vue` | `Select` (export format) |
| `PopupSlider.vue` | `Slider` |
| `ViewerLightControls.vue` | `Slider` |
| `ViewerSceneControls.vue` | `Checkbox` → native `<input
type="checkbox">` |

## Implementation notes

- `Select` uses `@/components/ui/select/*` compound components. Numeric
model values (animation speed index) are stringified at the binding
boundary and converted back on update, matching Reka `SelectRoot`'s
`string`-only `modelValue` contract.
- `Slider` uses `@/components/ui/slider/Slider.vue`. Single-number
`defineModel` values are wrapped in a `computed` array and unwrapped in
the update handler, following the pattern established in
`LightControls.vue`.
- No new Reka UI wrapper components were created — existing ui/select
and ui/slider primitives were used directly.

## Test 

https://github.com/user-attachments/assets/afca0fc8-a7b6-49ee-b221-ee5725bd127e
1. AnimationControls.vue
- **Add Load3D node** → Upload an animated GLB file (e.g., a character
model).
- **Node preview top bar:** Play/Pause button, speed dropdown, animation
name dropdown, and progress bar.

2. PopupSlider.vue
- **Hover over Load3D preview:** Icon buttons appear in the left
toolbar.
- **"Light Intensity" button (bulb icon)** → Slider pops up on the
right.
- **"FOV" button (view icon)** → Slider pops up on the right.

3. ViewerCameraControls.vue
- **Load3D node** → Settings panel (top-right) → **"Camera"** tab.
- **Features:** Camera type dropdown (Perspective / Orthographic), FOV
slider (visible in Perspective mode).

4. ViewerExportControls.vue
- **Settings panel** → **"Export"** tab.
- **Features:** Format dropdown (GLB / OBJ / STL), Export button.

5. ViewerLightControls.vue
- **Settings panel** → **"Light"** tab.
- **Features:** Light intensity slider.

6. ViewerModelControls.vue
- **Settings panel** → **"Model"** tab.
- **Features:** "Up direction" dropdown, Material mode dropdown
(Wireframe / Normal, etc.).

7. ViewerSceneControls.vue
- **Settings panel** → **"Scene"** tab.
- **Features:** Background color picker, "Show grid" checkbox, upload
background image button.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> UI component swap touches multiple interactive viewer controls
(selects/sliders/checkbox), so small binding/typing differences (string
vs number, array slider values) could cause subtle regressions despite
test updates.
> 
> **Overview**
> Replaces PrimeVue `Select`, `Slider`, and `Checkbox` usages across
Load3D viewer controls with the project’s Reka UI-based primitives
(`@/components/ui/select/*`, `@/components/ui/slider/Slider.vue`) and a
native checkbox.
> 
> Updates v-model wiring to match the new components’ contracts: selects
now bind via string `modelValue` with explicit number casting where
needed, and sliders now wrap single numeric values into `[number]`
arrays with corresponding update handlers. Unit tests are updated to
mock the new UI components and their updated event/value shapes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
46f99db256. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12020-refactor-load3d-replace-PrimeVue-Select-Slider-Checkbox-with-Reka-UI-3586d73d365081f58601d93031016afd)
by [Unito](https://www.unito.io)
2026-05-06 19:30:25 -04:00
Benjamin Lu
d3ab2be695 test: reuse queue button page object in e2e (#11927)
## Summary

Reuse the actionbar queue-button page object in the queue mode E2E tests
so dropdown selectors live in one helper.

## Changes

- **What**: Adds queue mode menu/item helpers to
`ComfyActionbar.queueButton` and updates `queueButtonModes.spec.ts` to
use them.
- **Dependencies**: None.

## Review Focus

This is stacked on #11209 and should be reviewed as a test-infra cleanup
only; the behavior covered by the spec is unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11927-test-reuse-queue-button-page-object-in-e2e-3566d73d365081918d59c2d587c4c94a)
by [Unito](https://www.unito.io)
2026-05-06 15:07:57 +00:00
pythongosssss
37f0fbcbef fix: add guard to prevent user store re-initialization (#11959)
## Summary

Make `userStore.initialize()` idempotent and concurrency-safe so the
bootstrap, router-guard, and UserSelectView callers share a single
getUserConfig fetch instead of racing/duplicaitng calls.

## Changes

- **What**: 
- cache initialize in a promise so callers all re-use the same result
- remove now redundant is initialized guard
- tests

## Review Focus
- Current user switch/logout uses `window.location.reload()`, no callers
intentionally call initialize to reinit. In future if this changes we
may want to add a parameter to skip the cache or a separate function.
- Failed initializes are not cached to allow callers to retry

- Not practical for e2e tests, the unit tests prove that the requests
are deduped. All a e2e test would do is mock/spy on the network requests
to show multiple requests do not happen - which the unit tests do a
better job of.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11959-fix-add-guard-to-prevent-user-store-re-initialization-3576d73d3650817db7b0e52cc25f9b7b)
by [Unito](https://www.unito.io)
2026-05-06 12:57:36 +00:00
Terry Jia
6ef051f200 FE-537: fix(load3d): preserve camera view, fit transform, and first-frame paint after refresh (#11944)
## Summary

- Defer thumbnail capture until camera state is restored via new
modelReady event so captureThumbnail no longer races with the saved
view, fixing the "snap back to default on hover" regression.
- Repaint the live scene at the end of captureThumbnail so the canvas is
not left with the offscreen mask/normal pass when the render loop is
gated.
- Persist post-fitToViewer model.scale + model.position into the
existing modelConfig.gizmo slot so a refresh reapplies them via the
existing applyGizmoConfigToLoad3d path; rotation stays owned by
upDirection.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11944-FE-537-fix-load3d-preserve-camera-view-fit-transform-and-first-frame-paint-after-re-3576d73d365081429653ea4740612617)
by [Unito](https://www.unito.io)
2026-05-06 08:43:54 -04:00
Dante
0788e71394 feat(dialog): introduce Reka-UI dialog primitives + opt-in renderer branch (Phase 0) (#11719)
## Summary

Lands the renderer infrastructure for migrating ComfyUI Frontend's
central dialog system from PrimeVue to Reka-UI. **Phase 0 of a phased
migration.** No production dialog migrates in this PR — every existing
dialog continues to render through PrimeVue exactly as before.

## Motivation

GitHub issue #11688 surfaced a PrimeVue Dialog `max-width` design
limitation that is awkward to address through PrimeVue's pass-through
styling. ADR 0004 (Rejected, 2025-08-27) explicitly endorses **selective
component replacement with shadcn/Reka-UI** as the path forward for
problematic PrimeVue components, and `AGENTS.md` already directs
contributors to "Avoid new usage of PrimeVue components." The dialog
system is a strong first candidate: clean public API boundary
(`useDialogService` / `dialogStore`), bounded surface (~12 dialogs), and
Reka-UI is already in use elsewhere in the codebase. The #11688 fix
arrives naturally in Phase 1 once `prompt`/`confirm` migrate to the new
primitive's `md` default (`max-width: 36rem`).

## Phased migration plan

This PR is **Phase 0 only**. Each subsequent phase is shipped as its own
PR.

| Phase | Scope | Approx LOC |
| ----- | ----- | ---------- |
| **0 (this PR)** | Reka-UI primitive set under
`src/components/ui/dialog/` + opt-in renderer branch in
`GlobalDialog.vue` + tests + Storybook | ~600 |
| 1 | Migrate `PromptDialogContent` + `ConfirmationDialogContent`;
closes #11688 | ~250 |
| 2 | Migrate `ErrorDialogContent`, `NodeSearchBox(Popover)`,
`SecretFormDialog`, `VideoHelpDialog`, `CustomizationDialog` | ~400 |
| 3 | Migrate Settings dialog (workspace + non-workspace variants) —
designer review | ~300 |
| 4 | Migrate Manager dialog — designer review | ~300 |
| 5 | Migrate `ConfirmDialog` callers (`SecretsPanel`,
`BaseWorkflowsSidebarTab`) | ~150 |
| 6 | Remove PrimeVue Dialog/ConfirmDialog imports + clean up CSS
overrides | ~200 |

Full plan in `temp/plans/dialog-migration-phase-0.md` and ADR draft at
`temp/plans/adr-0009-dialog-reka-migration-DRAFT.md` (will move to
`docs/adr/` after team review).

## Changes

- **What**:
- New shadcn-style primitives at `src/components/ui/dialog/` wrapping
Reka-UI's `Dialog*` components: `Dialog`, `DialogPortal`,
`DialogOverlay`, `DialogContent`, `DialogHeader`, `DialogFooter`,
`DialogTitle`, `DialogDescription`, `DialogClose`. Variants via `cva`
with sizes `sm | md | lg | xl | full`.
- `dialogStore.CustomDialogComponentProps` gains opt-in `renderer?:
'primevue' | 'reka'` (default `'primevue'`) and `size?: 'sm' | 'md' |
'lg' | 'xl' | 'full'`.
- `GlobalDialog.vue` branches the per-stack-item template based on the
`renderer` flag. PrimeVue path is byte-identical to before.
  - Storybook stories: `Default`, `LongContent`, `Headless`, `AllSizes`.
- Unit tests verifying branch selection and that the opt-in flag is
preserved on the dialog stack item.
- **Breaking**: None. Default renderer is `primevue` and no production
dialog opts in.
- **Dependencies**: None. Reka-UI is already a workspace dependency.

## Review Focus

1. **API surface**: `useDialogService` / `dialogStore` public API is
unchanged. Custom-node extensions calling
`app.extensionManager.dialog.*` continue to work.
2. **Renderer branch wiring** in `GlobalDialog.vue` — `escape-key-down`
/ `pointer-down-outside` map to `closeOnEscape` / `dismissableMask`;
`mousedown` calls `dialogStore.riseDialog` to mirror the PrimeVue
PT-based behavior.
3. **Primitive defaults** — `md` size = 36rem max-width (chosen to
resolve #11688 in Phase 1); `full` = `calc(100vw - 1rem)` escape hatch
for Settings/Manager later.
4. **No behavior change**: existing dialogs continue to render unchanged
because nothing opts into `renderer: 'reka'` in this PR.

## Quality gates

- `pnpm typecheck` — clean
- `pnpm lint` — clean (1 pre-existing warning unrelated to this PR)
- `pnpm test:unit` — 48 dialog-adjacent tests pass including 3 new tests
in `GlobalDialog.test.ts`
- `pnpm format` — applied

knip pre-push noise (unused deps in workspace packages, unused
`types.gen.ts`) is pre-existing on `main` and not introduced by this PR.

## Out of scope (deferred)

- Migrating any production dialog — Phase 1+
- Removing PrimeVue dependency — Phase 6
- Touching legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`) — separate
cleanup
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue` against the new
primitives — separate cleanup

Refs #11688

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11719-feat-dialog-introduce-Reka-UI-dialog-primitives-opt-in-renderer-branch-Phase-0-3506d73d365081fc8c83ceadbffd276c)
by [Unito](https://www.unito.io)


# test

## checklist

| Scenario | Cloud prod | This PR | Notes |
  |---|---|---|---|
| Confirm dialog (delete/sign‑out) |  |  | OK/Cancel, ESC, backdrop
click identical |
| Prompt dialog (rename / save as) |  |  | Enter submits, ESC cancels,
focus trap intact |
| Settings dialog open/close |  |  | Tabs, search, ESC, save
persistence unchanged |
| Manager dialog |  |  | Tab switching, sub‑confirm stacking, z‑index
correct |
| Stacked dialog ESC handling |  |  | Only top dialog closes;
mousedown raises bottom |
| Dialog after route change |  |  | No orphaned overlay, no body
scroll lock leak |
| `.p-dialog` DOM attrs | clean | clean | No `renderer=` / `size=`
attribute leak from new optional fields |


## screenshot 
<img width="1616" height="927" alt="Screenshot 2026-05-06 at 8 43 10 PM"
src="https://github.com/user-attachments/assets/c6f668c2-a537-45ae-bf66-8bb0617502de"
/>
<img width="1419" height="951" alt="Screenshot 2026-05-06 at 8 43 41 PM"
src="https://github.com/user-attachments/assets/d82d4b27-cb05-4185-be4a-bd2fb9503130"
/>
<img width="1884" height="1001" alt="Screenshot 2026-05-06 at 8 46
31 PM"
src="https://github.com/user-attachments/assets/dd13f99f-a11e-4b85-9f27-7d30c55cf266"
/>
<img width="1876" height="1009" alt="Screenshot 2026-05-06 at 8 47
29 PM"
src="https://github.com/user-attachments/assets/f9824b57-4a06-44d6-8f18-e1226c764c83"
/>
2026-05-06 12:04:15 +00:00
Yourz
d3f802de10 feat(pricing): add concurrent API jobs feature to Creator and Pro tiers (#12000)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a new feature bullet to the Creator and Pro plans on the [cloud
pricing page](https://comfy.org/cloud/pricing) to call out included API
concurrency:

- **Creator**: `3 concurrent API jobs`
- **Pro**: `5 concurrent API jobs`

Free and Standard tiers do not include API access, so they are not
changed.

This matches the language landing in the docs PR:
[Comfy-Org/docs#965](https://github.com/Comfy-Org/docs/pull/965).

## Changes

- `apps/website/src/components/pricing/PriceSection.vue`: added
`feature2` to the Creator and Pro plan feature lists.
- `apps/website/src/i18n/translations.ts`: added
`pricing.plan.creator.feature2` and `pricing.plan.pro.feature2` for `en`
and `zh-CN`.
- Updated `pricing-tiers-{1-sm,2-md,3-lg,4-xl}` visual regression
snapshots in `apps/website/e2e/visual-responsive.spec.ts-snapshots/` to
match the new copy.

## Verification

- `pnpm nx run @comfyorg/website:typecheck` — clean
- ESLint and `oxfmt` clean on changed files (pre-commit lint-staged also
passed)
- `pnpm exec playwright test --project visual -g "pricing-tiers"` — 4/4
passing against the regenerated snapshots
- Manually rendered `localhost:4321/cloud/pricing`; confirmed copy
appears in both desktop and mobile layouts and that Free / Standard are
unchanged. Screenshots below.

## Screenshots

### Desktop
![desktop](pricing-desktop)

### Mobile — Creator
![mobile creator](pricing-mobile-creator)

### Mobile — Pro
![mobile pro](pricing-mobile-pro)


## Screenshots


![pricing-desktop](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290283-b5f959c8-b0ba-42bf-a822-aa7c04b5a3dc.png)


![pricing-mobile-creator](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290725-2cf36ff3-bc0d-4746-b37d-e3445f4293ab.png)


![pricing-mobile-pro](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029291135-5fe2f975-a0ac-4aac-91bc-5602670d8bbf.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12000-feat-pricing-add-concurrent-API-jobs-feature-to-Creator-and-Pro-tiers-3586d73d365081559acfc44eb5024c52)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-06 11:46:32 +00:00
Kelly Yang
d78c630d36 test(maskeditor): expand useBrushDrawing behavioral coverage (#12001)
Adds targeted behavioral tests for the slimmed `useBrushDrawing`
orchestration composable (Phase E of the brush-drawing refactor).

## Changes

- 5 new tests covering previously untested branches:
  - `compositeStroke` receives `isRgb=true` when active layer is `rgb`
  - `compositeStroke` receives `isErasing=true` when tool is `eraser`
  - Mask canvas opacity is restored after drawing on the mask layer
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when tool is eraser
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when right mouse button is held

## Coverage (useBrushDrawing.ts)

| Metric | Before | After |
|--------|--------|-------|
| Statements | 86.33% | 87.05% |
| Branches | 68.75% | 70.00% |
| Functions | 90.00% | 90.00% |
| Lines | 89.23% | 90.00% |

All 18 tests pass. GPU paths remain `/* c8 ignore */` excluded
(untestable without WebGL).

- Fixes #0

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to unit tests, adding coverage for
eraser/right-click composition and `drawEnd` GPU compositing/opacity
restoration paths without altering production logic.
> 
> **Overview**
> Adds new `useBrushDrawing` test cases to cover previously untested
branches: setting `globalCompositeOperation` to `destination-out` during
`handleDrawing` when erasing (tool or right-click), and verifying
`drawEnd` passes correct `isRgb`/`isErasing` flags to
`gpu.compositeStroke`.
> 
> Also asserts mask-layer opacity is restored after `drawEnd`,
increasing behavioral coverage around stroke completion and canvas
visibility cleanup.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
20c411e6ce. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12001-test-maskeditor-expand-useBrushDrawing-behavioral-coverage-3586d73d365081388ebcef91c2172c0a)
by [Unito](https://www.unito.io)
2026-05-06 06:15:54 -04:00
Christian Byrne
aa4343a98b test: add perf test for subgraph transition bottleneck (#10480)
## Summary

Add a `@perf` test measuring the cost of entering a subgraph containing
80 interior nodes. Establishes a CI baseline for the synchronous
mount/unmount bottleneck.

## Changes

- **What**: Add `subgraph transition (enter and exit)` perf test to
`performance.spec.ts` and a test workflow asset
(`large-subgraph-80-nodes.json`) with a single subgraph node containing
80 Note nodes.

## Review Focus

This is PR 1 of 2. The test establishes a baseline on main so the
optimization PR (PR 2) can show a CI-proven delta for `taskDurationMs`
and `totalBlockingTimeMs`.

The test:
1. Loads the 80-node subgraph workflow
2. Enters and exits once to warm up
3. Measures a fresh enter transition (start → 80 nodes mounted → layout
settled)
4. Records `taskDurationMs`, `layouts`, and `TBT`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10480-test-add-perf-test-for-subgraph-transition-bottleneck-32d6d73d3650811b9b6eec03a9591f82)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-06 01:54:18 -07:00
Benjamin Lu
270c7e34f4 fix: hide Google free-tier copy in webviews (#11924)
Stacked on #10699.

- Hide Google-specific free-tier promo copy when embedded webviews block
Google SSO.
- Use GitHub-only fallback copy when returning from email signup in that
state.
- Remove the unused export from the Google SSO blocked-reason type so
knip stays clean.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11924-fix-hide-Google-free-tier-copy-in-webviews-3566d73d36508168be7ed28cbe455d9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-05-06 01:49:40 -07:00
Christian Byrne
666684e6e6 fix: stop PreviewAny widgets from triggering re-execution (#12010)
## Summary

Preview as Text (`PreviewAny`) nodes were re-executing on every prompt
submission because the rendered preview text was being echoed back to
the backend as input values, mutating the cache signature.

## Changes

- **What**: Set `widget.options.serialize = false` on the three widgets
the `Comfy.PreviewAny` extension adds (`preview_markdown`,
`preview_text`, `previewMode`) so they are excluded from the API prompt
sent to the backend.

## Root cause

The extension was setting `widget.serialize = false`, which only
controls **workflow JSON** persistence (checked in
`LGraphNode.serialize`). The **API prompt** serializer in
`executionUtil.graphToPrompt` checks `widget.options.serialize` instead
— a distinct property documented in litegraph's `WIDGET_SERIALIZATION`
convention.

After `onExecuted` writes the rendered text into the widget value, the
next `graphToPrompt` call serialized that text into
`inputs.preview_text` / `inputs.preview_markdown`. The backend cache
signature in `comfy_execution/caching.py` hashes all keys in
`node["inputs"]`, so the changing text invalidated the cache and forced
a redundant execution every time.

## Review Focus

Two commits, red-green TDD:
1. `test:` failing unit + e2e tests asserting the desired behavior.
2. `fix:` adds `options.serialize = false` to make them pass.

Tests verify the widgets are excluded from the API prompt; e2e
additionally simulates a prior execution populating widget values to
mirror the real bug condition.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12010-fix-stop-PreviewAny-widgets-from-triggering-re-execution-3586d73d3650810585cdd077f3ac64f5)
by [Unito](https://www.unito.io)
2026-05-06 05:08:35 +00:00
Christian Byrne
4484b62854 test: add E2E tests for queue button modes (#11209)
## Changes

Add Playwright E2E tests for queue button modes in the topbar (6 tests):

- Run button visibility in topbar
- Queue mode trigger menu visibility
- Opening the mode menu via trigger click
- Verifying available modes are shown as menu item radios
- Menu closes after selecting a mode
- Run button sends prompt when clicked (via intercepted `/api/prompt`
route)

## Test

```bash
pnpm test:browser:local -- browser_tests/tests/queueButtonModes.spec.ts
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11209-test-add-E2E-tests-for-queue-button-modes-3416d73d365081a5bf10f8b9c6bdc2a7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-06 03:30:32 +00:00
Christian Byrne
d29169ff4e test: add E2E tests for keybinding settings panel coverage (#11455)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds 22 Playwright E2E tests for the keybinding settings panel
(`KeybindingPanel.vue`), covering all 15 previously-untested functions
identified via coverage analysis.

## Test Groups

| Group | Tests | Functions Covered |
|---|---|---|
| Row Expansion | 2 | `handleRowClick`, `toggleExpanded`, `expandedRows`
|
| Double-Click | 2 | `handleRowDblClick`, `addKeybinding`,
`editKeybinding` |
| Context Menu | 7 | `handleRowContextMenu`, `clearContextMenuTarget`,
`ctxChangeKeybinding`, `ctxAddKeybinding`, `ctxResetToDefault`,
`ctxRemoveKeybinding` |
| Action Buttons | 7 | `editKeybinding`, `resetKeybinding`,
`removeSingleKeybinding`, `handleRemoveAllKeybindings`,
`handleRemoveKeybindingFromMenu` |
| Expanded Row Actions | 2 | `editKeybinding` (expansion),
`removeSingleKeybinding` (expansion) |
| Reset All | 2 | `resetAllKeybindings` (confirm + cancel) |
| Search Filter | 1 | `watch(filters, ...)` clears expansion |

## Flake Prevention Measures

- Deterministic test command
(`TestCommand.KeybindingPanelE2E.NoBinding`) registered via
`app.registerExtension()` for 0-binding scenarios — avoids flaky
pagination-dependent row scanning
- `pressComboOnInput()` asserts input focus before pressing key combos
in the edit dialog
- `saveAndCloseKeybindingDialog()` waits for dialog teardown to complete
before proceeding
- `openContextMenu()` waits for Reka UI menu items to be visible before
interacting
- Resets `Comfy.Keybinding.NewBindings` and
`Comfy.Keybinding.UnsetBindings` in `afterEach`

## Note on Selectors

Some locators use Tailwind utility classes (`.pl-4`,
`.icon-[lucide--chevron-right]`) because the expansion template and
chevron icon in `KeybindingPanel.vue` lack `data-testid` attributes.
Adding test IDs would be a follow-up to this test-only PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11455-test-add-E2E-tests-for-keybinding-settings-panel-coverage-3486d73d365081d7a902fc68091552f2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-05 20:24:13 -07:00
172 changed files with 11066 additions and 1634 deletions

View File

@@ -54,10 +54,14 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
- name: Upload perf metrics
if: always()

View File

@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
locale: getDefaultLocale(),
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [{ text: 'pricing.plan.creator.feature1' }],
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
isPopular: true
},
{
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [{ text: 'pricing.plan.pro.feature1' }]
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
},
{
id: 'enterprise',

View File

@@ -1119,6 +1119,10 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1143,6 +1147,10 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {

File diff suppressed because it is too large Load Diff

View File

@@ -352,6 +352,12 @@ export class ComfyPage {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}

View File

@@ -217,13 +217,20 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
const editButton = this.getSubgraphEnterButton(nodeId)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -39,10 +39,32 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
public async openOptions() {
const options = new ComfyQueueButtonOptions(this.actionbar.page)
if (!(await options.menu.isVisible())) {
await this.dropdownButton.click()
}
return options
}
}
class ComfyQueueButtonOptions {
constructor(public readonly page: Page) {}
public readonly menu: Locator
public readonly modeItems: Locator
constructor(public readonly page: Page) {
this.menu = page.getByRole('menu')
this.modeItems = this.menu.getByRole('menuitem')
}
public modeItem(name: string) {
return this.menu.getByRole('menuitem', { name, exact: true })
}
public async selectMode(name: string) {
await this.modeItem(name).click()
}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {

View File

@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -215,11 +215,12 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -296,15 +297,29 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(tags: string[]): Asset[] {
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

View File

@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -20,43 +85,20 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
}
}

View File

@@ -8,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -75,7 +76,15 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -0,0 +1,250 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

View File

@@ -0,0 +1,47 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

View File

@@ -0,0 +1,548 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_BINDING_COMMAND = 'Comfy.Canvas.DeleteSelectedItems'
const SINGLE_BINDING_COMMAND = 'Comfy.SaveWorkflow'
const NO_BINDING_COMMAND = 'TestCommand.KeybindingPanelE2E.NoBinding'
async function searchKeybindings(page: Page, query: string) {
await getKeybindingSearchInput(page).fill(query)
}
async function clearSearch(page: Page) {
await getKeybindingSearchInput(page).clear()
}
function getKeybindingSearchInput(page: Page): Locator {
return page.getByPlaceholder('Search Keybindings...')
}
function getCommandRow(page: Page, commandId: string): Locator {
return page
.locator('.keybinding-panel tr')
.filter({ has: page.locator(`[title="${commandId}"]`) })
}
function getExpansionContent(page: Page, commandId: string): Locator {
// PrimeVue renders the expansion row as the next sibling <tr> of the
// expanded row. Scoping by sibling avoids matching unrelated expanded rows.
return getCommandRow(page, commandId)
.locator('xpath=following-sibling::tr[1]')
.getByTestId('keybinding-expansion-content')
}
async function openContextMenu(page: Page, commandId: string) {
const row = getCommandRow(page, commandId)
await row.locator(`[title="${commandId}"]`).click({ button: 'right' })
await expect(
page.getByRole('menuitem', { name: /Change keybinding/i })
).toBeVisible()
}
function getKeybindingInput(page: Page): Locator {
return getEditKeybindingDialog(page).locator('input[autofocus]')
}
function getEditKeybindingDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Modify keybinding/i })
}
function getRemoveAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Remove all keybindings/i })
}
function getResetAllKeybindingsDialog(page: Page): Locator {
return page.getByRole('dialog', { name: /Reset all keybindings/i })
}
async function pressComboOnInput(page: Page, combo: string) {
const input = getKeybindingInput(page)
await expect(input).toBeFocused()
await input.press(combo)
}
async function saveAndCloseKeybindingDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Save/i }).click()
await expect(dialog).toBeHidden()
}
async function cancelAndCloseDialog(page: Page) {
const dialog = getEditKeybindingDialog(page)
await dialog.getByRole('button', { name: /Cancel/i }).click()
await expect(dialog).toBeHidden()
}
async function addKeybindingToRow(page: Page, row: Locator, combo: string) {
await row.getByRole('button', { name: /Add new keybinding/i }).click()
await pressComboOnInput(page, combo)
await saveAndCloseKeybindingDialog(page)
}
test.beforeEach(async ({ comfyPage }) => {
await registerNoBindingCommand(comfyPage)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [])
await comfyPage.settings.setSetting('Comfy.Keybinding.UnsetBindings', [])
})
async function registerNoBindingCommand(comfyPage: ComfyPage) {
await comfyPage.page.evaluate((commandId) => {
const app = window.app!
app.registerExtension({
name: 'TestExtension.KeybindingPanelE2E',
commands: [{ id: commandId, function: () => {} }]
})
}, NO_BINDING_COMMAND)
}
test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
test.describe('Row Expansion', () => {
test('Click on row with 2+ keybindings toggles expansion', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
await row.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
await expect(expansionContent).toBeHidden()
})
test('Click on row with 1 keybinding does not expand', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, SINGLE_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
})
})
test.describe('Double-Click', () => {
test('Double-click row with 0 keybindings opens Add dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${NO_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Double-click row with 1 keybinding opens Edit dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(row).toBeVisible()
await row.locator(`[title="${SINGLE_BINDING_COMMAND}"]`).dblclick()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
})
test.describe('Context Menu', () => {
test('Right-click row shows context menu with correct items', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const addItem = page.getByRole('menuitem', {
name: /Add new keybinding/i
})
const resetItem = page.getByRole('menuitem', {
name: /Reset to default/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toBeVisible()
await expect(addItem).toBeVisible()
await expect(resetItem).toBeVisible()
await expect(removeItem).toBeVisible()
await page.keyboard.press('Escape')
})
test("Context menu 'Add new keybinding' opens add dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on single-binding command opens edit dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test("Context menu 'Change keybinding' on multi-binding command expands row", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeHidden()
await openContextMenu(page, MULTI_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Change keybinding/i }).click()
await expect(expansionContent).toBeVisible()
})
test("Context menu 'Remove keybinding' after adding second binding shows confirm dialog", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F9')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Remove keybinding/i }).click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Remove all/i }).click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test("Context menu 'Reset to default' resets modified command", async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F10')
await openContextMenu(page, SINGLE_BINDING_COMMAND)
await page.getByRole('menuitem', { name: /Reset to default/i }).click()
await expect(row.getByRole('button', { name: /Reset/i })).toBeDisabled()
})
test('Context menu items disabled when no keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
await openContextMenu(page, NO_BINDING_COMMAND)
const changeItem = page.getByRole('menuitem', {
name: /Change keybinding/i
})
const removeItem = page.getByRole('menuitem', {
name: /Remove keybinding/i
})
await expect(changeItem).toHaveAttribute('data-disabled', '')
await expect(removeItem).toHaveAttribute('data-disabled', '')
await page.keyboard.press('Escape')
})
})
test.describe('Action Buttons', () => {
test('Edit button opens edit dialog for single-binding command', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const editButton = row.getByRole('button', { name: /^Edit$/i })
await expect(editButton).toBeVisible()
await editButton.click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Add button opens add dialog', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await row.getByRole('button', { name: /Add new keybinding/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Reset button is disabled for unmodified commands', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeDisabled()
})
test('Reset button resets modified keybinding', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F11')
const resetButton = row.getByRole('button', { name: /Reset/i })
await expect(resetButton).toBeEnabled()
await resetButton.click()
await expect(resetButton).toBeDisabled()
})
test('Delete button is disabled for commands with 0 keybindings', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeDisabled()
})
test('Delete button removes single keybinding directly', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, NO_BINDING_COMMAND)
const row = getCommandRow(page, NO_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F12')
const deleteButton = row.getByRole('button', { name: /Delete/i })
await expect(deleteButton).toBeEnabled()
await deleteButton.click()
await expect(row.locator('td').nth(1)).toContainText('-')
})
test('Delete button on command with 2+ keybindings shows confirm dialog', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const deleteButton = row.getByRole('button', { name: /Delete/i })
await deleteButton.click()
const confirmDialog = getRemoveAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
await expect(row.locator('td').nth(1)).not.toContainText('-')
})
})
test.describe('Expanded Row Actions', () => {
test('Edit button in expanded row opens edit dialog for that binding', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const firstBindingRow = expansionContent
.getByTestId('keybinding-expansion-binding')
.first()
await firstBindingRow.getByRole('button', { name: /^Edit$/i }).click()
const input = getKeybindingInput(page)
await expect(input).toBeVisible()
await cancelAndCloseDialog(page)
})
test('Delete button in expanded row removes that binding and collapses', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
const bindingRows = expansionContent.getByTestId(
'keybinding-expansion-binding'
)
await expect
.poll(() => bindingRows.count(), {
message: 'Expected at least 2 bindings'
})
.toBeGreaterThanOrEqual(2)
const initialBindingCount = await bindingRows.count()
await bindingRows
.first()
.getByRole('button', { name: /Remove keybinding/i })
.click()
if (initialBindingCount === 2) {
// Expansion auto-collapses when bindings drop below 2
await expect(expansionContent).toBeHidden()
} else {
await expect(bindingRows).toHaveCount(initialBindingCount - 1)
}
})
})
test.describe('Reset All', () => {
test('Reset All button shows confirmation and resets on confirm', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const row = getCommandRow(page, SINGLE_BINDING_COMMAND)
await addKeybindingToRow(page, row, 'Control+Shift+F8')
await expect(row.getByRole('button', { name: /Reset/i })).toBeEnabled()
await clearSearch(page)
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await expect(confirmDialog).toContainText(/Reset all keybindings/i)
await confirmDialog.getByRole('button', { name: /Reset All/i }).click()
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
await searchKeybindings(page, SINGLE_BINDING_COMMAND)
const rowAfterReset = getCommandRow(page, SINGLE_BINDING_COMMAND)
await expect(
rowAfterReset.getByRole('button', { name: /Reset/i })
).toBeDisabled()
})
test('Reset All confirmation can be cancelled', async ({ comfyPage }) => {
const { page } = comfyPage
const resetAllButton = page
.locator('.keybinding-panel')
.getByRole('button', { name: /Reset All/i })
await resetAllButton.click()
const confirmDialog = getResetAllKeybindingsDialog(page)
await expect(confirmDialog).toBeVisible()
await confirmDialog.getByRole('button', { name: /Cancel/i }).click()
await expect(confirmDialog).toBeHidden()
})
})
test.describe('Search Filter', () => {
test('Typing in search clears expanded rows', async ({ comfyPage }) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
await page.locator(`[title="${MULTI_BINDING_COMMAND}"]`).click()
const expansionContent = getExpansionContent(page, MULTI_BINDING_COMMAND)
await expect(expansionContent).toBeVisible()
// Changing the filter triggers watch(filters, ...) which clears expansion
await searchKeybindings(page, MULTI_BINDING_COMMAND + ' ')
await expect(expansionContent).toBeHidden()
})
})
})

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test(
'subgraph transition (enter and exit)',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }, testInfo) => {
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
// The signal is dominated by N=80 mount cost, so a single sample per
// CI invocation is sufficient — early-return on subsequent repeats.
if (testInfo.repeatEachIndex > 0) return
// Load workflow with a subgraph containing 80 interior nodes.
// Entering the subgraph unmounts root nodes and mounts all 80 interior
// nodes synchronously — this is the bottleneck we're measuring.
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
await comfyPage.idleFrames(30)
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
// Exit back to root graph before measuring a fresh enter/exit cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.idleFrames(10)
// Start measuring the enter transition
await comfyPage.perf.startMeasuring()
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
recordMeasurement(m)
console.log(
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
}
)
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -0,0 +1,42 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
const runOnChangeLabel = queueModeLabels[1]
test.describe('Queue button modes', { tag: '@ui' }, () => {
test('Run button is visible in topbar', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
})
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
})
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
})
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
await expect(options.modeItems).toHaveText(queueModeLabels)
})
test('Selecting a non-default mode updates the Run button label', async ({
comfyPage
}) => {
const queueButton = comfyPage.actionbar.queueButton
const options = await queueButton.openOptions()
await expect(options.menu).toBeVisible()
await options.selectMode(runOnChangeLabel)
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
})
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route('**/api/prompt', async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
})
await comfyPage.actionbar.queueButton.primaryButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,14 +54,44 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens properties panel', async ({ comfyPage }) => {
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -18,70 +19,19 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
})
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
await expect(nodeInfoButton).toBeHidden()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -90,11 +40,14 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -0,0 +1,148 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.page.goto(
new URL(
`/?share=${sharedWorkflowImportScenario.shareId}`,
comfyPage.url
).toString()
)
await comfyPage.waitForAppReady()
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,4 +120,13 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -106,6 +106,54 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.page.goto(`${comfyPage.url}/api/users`)
await comfyPage.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
await comfyPage.page.goto(
new URL('/?share=test-share-id', comfyPage.url).toString()
)
await comfyPage.waitForAppReady()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -131,48 +179,51 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const localeRequest = await localeRequestPromise
const englishRequest = await englishRequestPromise
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(englishRequest.url()).toContain('templates/index.json')
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

@@ -75,6 +75,24 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

View File

@@ -1,3 +1,5 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height * 0.75
}
await comfyPage.canvasOps.dragAndDrop(start, {
x: start.x + 120,
y: start.y + 80
})
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
await expect(widgets).toHaveCount(2)
const beforePos = await node.boundingBox()
if (!beforePos) throw new Error('Node has no bounding box')
await dragFromTabButton(comfyPage, showButton)
await expect(showButton).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
await expect(widgets).toHaveCount(2)
const afterPos = await node.boundingBox()
if (!afterPos) throw new Error('Node missing after drag')
await expectPosChanged(beforePos, afterPos)
})
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const beforePos = await subgraphNode.getPosition()
await dragFromTabButton(
comfyPage,
comfyPage.vueNodes.getSubgraphEnterButton('2')
)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
const afterPos = await subgraphNode.getPosition()
await expectPosChanged(beforePos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {

View File

@@ -4,6 +4,7 @@ import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -13,7 +14,9 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import type { NodeError } from '@/schemas/apiSchema'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -22,6 +25,61 @@ const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[loadImageId]: buildLoadImageRequiredInputError()
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
}
}
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -191,6 +249,74 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {

View File

@@ -26,6 +26,10 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.18",
"version": "1.44.19",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -524,9 +524,18 @@ export type ImportPublishedAssetsRequest = {
*/
published_asset_ids: Array<string>
/**
* The share ID of the published workflow these assets belong to. Required for authorization.
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
*/
share_id: string
share_id?: string | null
}
/**

View File

@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
share_id: z.string().min(1).max(64)
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
})
/**

View File

@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
})
})
describe('joinFilePath', () => {
it('joins subfolder and filename with normalized slash separators', () => {
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
'nested/folder/child/file.png'
)
})
it('trims boundary separators without changing the filename body', () => {
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
'nested/folder/file.png'
)
})
it('returns the normalized filename when no subfolder is provided', () => {
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
})
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
})
})
describe('getFilePathSeparatorVariants', () => {
it('returns slash and backslash variants for nested paths', () => {
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
'nested/folder/file.png',
'nested\\folder\\file.png'
])
})
it('returns a single value when no separator is present', () => {
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

View File

@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
export function joinFilePath(
subfolder: string | null | undefined,
filename: string | null | undefined
): string {
const normalizedSubfolder = normalizeFilePathSeparators(
subfolder ?? ''
).replace(/^\/+|\/+$/g, '')
const normalizedFilename = normalizeFilePathSeparators(
filename ?? ''
).replace(/^\/+/g, '')
if (!normalizedSubfolder) return normalizedFilename
if (!normalizedFilename) return normalizedSubfolder
return `${normalizedSubfolder}/${normalizedFilename}`
}
export function getFilePathSeparatorVariants(filepath: string): string[] {
const slashPath = normalizeFilePathSeparators(filepath)
const backslashPath = slashPath.replace(/\//g, '\\')
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
}
function normalizeFilePathSeparators(filepath: string): string {
return filepath.replace(/[\\/]+/g, '/')
}
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash

20
src/base/wheelGestures.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -40,7 +40,10 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -48,6 +51,7 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -62,6 +66,7 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -76,6 +81,7 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -92,6 +98,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -0,0 +1,192 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } },
missingWarn: false,
fallbackWarn: false
})
const Body = defineComponent({
name: 'Body',
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
})
}
describe('GlobalDialog renderer branching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
})
it('renders the Reka branch when renderer is reka', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-opt-in',
title: 'Reka dialog',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it('preserves the renderer flag on the dialog stack item', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-flag-check',
title: 'Reka',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
expect(item?.dialogComponentProps.renderer).toBe('reka')
})
})
describe('GlobalDialog Reka parity with PrimeVue', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('omits the close button when closable is false', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-not-closable',
title: 'No close',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
})
it('renders the close button by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-closable',
title: 'Closable',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('omits the title when headless is true', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-headless',
title: 'Hidden title',
component: Body,
dialogComponentProps: { renderer: 'reka', headless: true }
})
await screen.findByRole('dialog')
expect(screen.queryByText('Hidden title')).toBeNull()
})
it('renders the title when headless is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-titled',
title: 'Visible title',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByText('Visible title')).toBeInTheDocument()
})
it('closes the dialog on Escape by default', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-default',
title: 'Esc closes',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
})
it('does not close on Escape when closable is false', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-blocked',
title: 'Esc blocked',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})

View File

@@ -1,49 +1,106 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<template v-for="item in dialogStore.dialogStack" :key="item.key">
<Dialog
v-if="isRekaItem(item)"
:open="item.visible"
:modal="item.dialogComponentProps.modal ?? true"
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>
item.dialogComponentProps.closeOnEscape === false &&
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
<PrimeDialog
v-else
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</PrimeDialog>
</template>
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
@@ -53,6 +110,14 @@ const teamWorkspacesEnabled = computed(
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
return item.dialogComponentProps.renderer === 'reka'
}
function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps

View File

@@ -1,5 +1,8 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -15,10 +18,12 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -193,11 +198,12 @@
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4">
<div class="pl-4" data-testid="keybinding-expansion-content">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
data-testid="keybinding-expansion-binding"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
@@ -237,6 +243,7 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -313,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -336,6 +344,8 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,7 +9,10 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
@@ -46,6 +49,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
const { presetNames, contentStyle } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -10,7 +10,7 @@
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
@click="onItemClick($event, item)"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
@@ -21,20 +21,27 @@
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu"
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
<SubmenuPopover
v-if="colorOption"
ref="colorPickerMenu"
key="color-picker-menu"
ref="colorSubmenu"
key="color-submenu"
:option="colorOption"
@submenu-click="handleColorSelect"
@submenu-click="handleSubmenuSelect"
/>
<SubmenuPopover
v-if="shapeOption"
ref="shapeSubmenu"
key="shape-submenu"
:option="shapeOption"
@submenu-click="handleSubmenuSelect"
/>
</template>
@@ -54,16 +61,18 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
isShapeSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
@@ -150,21 +159,20 @@ useEventListener(
{ passive: true }
)
// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)
// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}
const shapeOption = computed(() =>
menuOptions.value.find((opt) => opt.isShapePicker)
)
// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }
const isColor = isColorOption(option)
const isColor = Boolean(option.isColorPicker)
const isShape = Boolean(option.isShapePicker)
const usesPopover = isColor || isShape
const item: ExtendedMenuItem = {
label: option.label,
@@ -172,11 +180,14 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
isShapeSubmenu: isShape,
originalOption: option
}
// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
// Submenus opened via popover (color, shape) deliberately omit `items` so
// PrimeVue does not render a nested <ul> inside the scrollable root list,
// which would be clipped when the menu overflows the viewport (FE-570).
if (option.hasSubmenu && option.submenu && !usesPopover) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
@@ -188,7 +199,6 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
}))
}
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
@@ -245,17 +255,30 @@ function toggle(event: Event) {
defineExpose({ toggle, hide, isOpen, show })
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
if (item.isColorSubmenu) {
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
} else if (item.isShapeSubmenu) {
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
}
}
// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
function openSubmenuPopover(
event: MouseEvent,
target: InstanceType<typeof SubmenuPopover> | undefined,
other: InstanceType<typeof SubmenuPopover> | undefined
) {
if (!target) return
event.stopPropagation()
event.preventDefault()
other?.hide()
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
target.toggle(event, anchor)
}
function handleSubmenuSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}
@@ -270,11 +293,17 @@ function constrainMenuHeight() {
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
const availableHeight = window.innerHeight - rect.top - 8
if (availableHeight <= 0) return
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
// to a non-visible value too (CSS spec), which clips horizontally-opening
// submenus like Shape. Only apply the constraint when content truly
// overflows so the common case keeps overflow visible.
if (rootList.scrollHeight <= availableHeight) return
rootList.style.maxHeight = `${availableHeight}px`
rootList.style.overflowY = 'auto'
}
function onMenuShow() {

View File

@@ -1,6 +1,7 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -29,6 +30,26 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
>
}
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
const defaultSettingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': true
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingValues = {
...defaultSettingValues,
...overrides
}
settingGetMock.mockImplementation(
(key: string): unknown => settingValues[key] ?? null
)
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
@@ -79,10 +100,7 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Load3D.3DViewerEnable') return true
return null
})
get: settingGetMock
})
}))
@@ -128,7 +146,7 @@ describe('SelectionToolbox', () => {
}
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
@@ -139,6 +157,7 @@ describe('SelectionToolbox', () => {
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
mockSettingValues()
})
function renderComponent(props = {}): { container: Element } {
@@ -231,6 +250,42 @@ describe('SelectionToolbox', () => {
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the new node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should show info button when new menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]

View File

@@ -16,8 +16,8 @@
@wheel="canvasInteractions.forwardEventToCanvas"
>
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
<InfoButton v-if="showInfoButton" />
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
<InfoButton v-if="canOpenNodeInfo" />
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
@@ -105,9 +105,8 @@ const {
isSingleImageNode,
hasAny3DNodeSelected,
hasOutputNodesSelected,
nodeDef
canOpenNodeInfo
} = useSelectionState()
const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,19 +8,20 @@ import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Button from '@/components/ui/button/Button.vue'
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
openNodeInfoMock: vi.fn(),
trackUiButtonClickedMock: vi.fn()
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
openNodeInfo: openNodeInfoMock
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: vi.fn()
trackUiButtonClicked: trackUiButtonClickedMock
})
}))
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
openNodeInfoMock.mockReturnValue(true)
})
const renderComponent = () => {
@@ -53,12 +53,29 @@ describe('InfoButton', () => {
})
}
it('should open the info panel on click', async () => {
const clickNodeInfoButton = async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
}
it('should open the node info panel on click', async () => {
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
await clickNodeInfoButton()
expect(openPanelMock).toHaveBeenCalledWith('info')
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
})
})
it('should not track the click when the node info panel is unavailable', async () => {
openNodeInfoMock.mockReturnValue(false)
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
})
})

View File

@@ -15,18 +15,16 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const rightSidePanelStore = useRightSidePanelStore()
const { openNodeInfo } = useSelectionState()
/**
* Track node info button click and toggle node help.
*/
const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
rightSidePanelStore.openPanel('info')
}
</script>

View File

@@ -8,7 +8,7 @@
unstyled
:pt="{
root: {
class: 'absolute z-60'
class: 'p-popover absolute z-60'
},
content: {
class: [
@@ -90,8 +90,12 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
const hide = () => {
popoverRef.value?.hide()
}
defineExpose({
toggle
toggle,
hide
})
const handleSubmenuClick = (subOption: SubMenuOption) => {

View File

@@ -21,20 +21,42 @@
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
@update:model-value="(val) => (selectedSpeed = Number(val))"
>
<SelectTrigger size="md" class="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in speedOptions"
:key="opt.value"
:value="String(opt.value)"
>
{{ opt.name }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
:model-value="
selectedAnimation != null ? String(selectedAnimation) : undefined
"
@update:model-value="(val) => (selectedAnimation = Number(val))"
>
<SelectTrigger size="md" class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="anim in animations"
:key="anim.index"
:value="String(anim.index)"
>
{{ anim.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
@@ -54,10 +76,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }

View File

@@ -5,20 +5,20 @@ import { ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="modelValue"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -15,21 +15,22 @@
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
>
<Slider
v-model="value"
:model-value="sliderValue"
class="w-full"
:min="min"
:max="max"
:step="step"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
const {
icon = 'pi-expand',
@@ -47,6 +48,12 @@ const {
const value = defineModel<number>()
const showSlider = ref(false)
const sliderValue = computed(() => [value.value ?? min])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) value.value = val[0]
}
const toggleSlider = () => {
showSlider.value = !showSlider.value
}

View File

@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
:aria-label="ariaLabel"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -2,34 +2,46 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
<Select v-model="cameraType">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="cam in cameras"
:key="cam.value"
:value="cam.value"
>
{{ cam.title }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:model-value="fovSliderValue"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
@update:model-value="onFovUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
@@ -41,4 +53,10 @@ const cameras = [
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const fovSliderValue = computed(() => [fov.value ?? 10])
function onFovUpdate(val: number[] | undefined) {
if (val?.length) fov.value = val[0]
}
</script>

View File

@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({

View File

@@ -1,11 +1,18 @@
<template>
<div class="space-y-4">
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
<Select v-model="exportFormat">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in exportFormats"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
@@ -19,10 +26,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
const emit = defineEmits<{
(e: 'exportModel', format: string): void

View File

@@ -17,19 +17,20 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -3,18 +3,20 @@
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@update:model-value="onSliderUpdate"
/>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderValue = computed(() => [
lightIntensity.value ?? lightIntensityMinimum
])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) lightIntensity.value = val[0]
}
</script>

View File

@@ -9,20 +9,65 @@ import type {
UpDirection
} from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
`
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({

View File

@@ -2,31 +2,51 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
<Select v-model="upDirection">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in upDirectionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
<Select v-model="materialMode">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in materialModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type {
MaterialMode,
UpDirection

View File

@@ -7,9 +7,15 @@
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
</div>
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
<div class="flex items-center gap-2">
<input
id="showGrid"
v-model="showGrid"
type="checkbox"
name="showGrid"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="showGrid" class="cursor-pointer">
{{ $t('load3d.showGrid') }}
</label>
</div>
@@ -58,7 +64,6 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -2,6 +2,7 @@
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
data-testid="node-preview-card"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div

View File

@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
describe('credits help icon (FE-617)', () => {
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
renderComponent()
const helpButton = screen.getByTestId('credits-info-button')
expect(helpButton).toBeInTheDocument()
expect(helpButton.tagName).toBe('BUTTON')
expect(helpButton).toHaveAttribute(
'aria-label',
enMessages.credits.unified.tooltip
)
})
})
it('opens user settings and emits close event when settings item is clicked', async () => {
const { user, onClose } = renderComponent()

View File

@@ -41,10 +41,16 @@
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<i
<Button
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
variant="muted-textonly"
size="icon-sm"
class="mr-auto"
:aria-label="$t('credits.unified.tooltip')"
data-testid="credits-info-button"
>
<i class="icon-[lucide--circle-help]" />
</Button>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"

View File

@@ -0,0 +1,188 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
const { sizes } = FOR_STORIES
const meta: Meta = {
title: 'Components/Dialog/Dialog',
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: sizes,
defaultValue: 'md'
}
},
args: {
size: 'md'
}
}
export default meta
type Story = StoryObj
export const Default: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open dialog</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2">
<DialogDescription>
This action cannot be undone. The selected items will be permanently removed.
</DialogDescription>
</div>
<DialogFooter>
<Button variant="textonly" @click="open = false">Cancel</Button>
<Button variant="destructive" @click="open = false">Delete</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const LongContent: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open long content</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Long content scrolls</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 space-y-2 overflow-auto">
<p v-for="n in 30" :key="n">
Paragraph {{ n }} — the dialog body should scroll independently
while the header and footer stay pinned.
</p>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const Headless: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent
},
setup() {
const open = ref(false)
return { open }
},
template: `
<Button @click="open = true">Open headless</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent size="sm" class="p-6">
<p class="text-sm">No header, no footer — fully custom content.</p>
<Button class="mt-4" @click="open = false">Close</Button>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const openSize = ref<string | null>(null)
return { openSize, sizes }
},
template: `
<div class="flex gap-2 flex-wrap">
<Button v-for="s in sizes" :key="s" @click="openSize = s">{{ s }}</Button>
</div>
<Dialog
v-for="s in sizes"
:key="s"
:open="openSize === s"
@update:open="(o) => { if (!o) openSize = null }"
>
<DialogPortal>
<DialogOverlay />
<DialogContent :size="s">
<DialogHeader>
<DialogTitle>Size: {{ s }}</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 text-sm">
The {{ s }} size variant.
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emit = defineEmits<DialogRootEmits>()
</script>
<template>
<DialogRoot v-bind="props" @update:open="(open) => emit('update:open', open)">
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { DialogClose } from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
</script>
<template>
<DialogClose as-child>
<slot>
<Button :aria-label="t('g.close')" size="icon" variant="muted-textonly">
<i class="icon-[lucide--x]" />
</Button>
</slot>
</DialogClose>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { DialogContentSize } from './dialog.variants'
import { dialogContentVariants } from './dialog.variants'
const {
size,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
class?: HTMLAttributes['class']
}
>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(restProps, emits)
</script>
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
>
<slot />
</DialogContent>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import { DialogDescription } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogDescription
v-bind="delegated"
:class="cn('text-sm text-muted-foreground', customClass)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4',
customClass
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-between gap-2 px-4 pt-4 pb-2',
customClass
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import { DialogOverlay } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogOverlay
v-bind="delegated"
:class="
cn(
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
customClass
)
"
/>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogPortalProps } from 'reka-ui'
import { DialogPortal } from 'reka-ui'
const props = defineProps<DialogPortalProps>()
</script>
<template>
<DialogPortal v-bind="props">
<slot />
</DialogPortal>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import { DialogTitle } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogTitleProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogTitle
v-bind="delegated"
:class="cn('text-base font-semibold text-base-foreground', customClass)"
>
<slot />
</DialogTitle>
</template>

View File

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

View File

@@ -50,7 +50,7 @@
position="popper"
:side-offset="8"
align="start"
:style="popoverStyle"
:style="[popoverStyle, contentStyle]"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
@@ -152,6 +152,7 @@ import {
ComboboxViewport
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -183,7 +184,8 @@ const {
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
@@ -207,6 +209,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItems = defineModel<SelectOption[]>({

View File

@@ -70,6 +70,7 @@
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:style="contentStyle"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
@@ -99,7 +100,7 @@
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes, StyleValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
@@ -132,7 +133,8 @@ const {
suggestions = [],
optionLabel,
optionKey,
class: className
class: className,
contentStyle
} = defineProps<{
placeholder?: string
icon?: string
@@ -144,6 +146,7 @@ const {
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -37,7 +37,7 @@
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:style="[optionStyle, contentStyle]"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
@@ -82,6 +82,7 @@ import {
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -108,7 +109,8 @@ const {
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
label?: string
options?: SelectOption[]
@@ -126,6 +128,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItem = defineModel<string | undefined>({ required: true })

View File

@@ -21,6 +21,12 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
beforeEach(() => {
vi.restoreAllMocks()
})
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
@@ -205,6 +211,47 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
node.type = 'LoadImage'
const widget = node.addWidget(
'combo',
'image',
'missing.png',
() => undefined,
{ values: [] }
)
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'missing.png',
isMissing: true
} satisfies MissingMediaCandidate
])
node.onWidgetChanged!.call(
node,
'image',
'uploaded.png',
'missing.png',
widget
)
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
@@ -347,6 +394,90 @@ describe('installErrorClearingHooks lifecycle', () => {
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
it('scans added-node missing models after widget values are restored', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
values: []
})
graph.add(node)
widget.value = 'fake_model.safetensors'
await Promise.resolve()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
})
it('scans added-node missing models before the deferred media scan', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockImplementation((_rootGraph, node) => [
{
nodeId: String(node.id),
nodeType: node.type,
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
} satisfies MissingModelCandidate
])
const mediaScan = vi
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
.mockReturnValue([])
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
graph.add(node)
await Promise.resolve()
expect(modelScan).toHaveBeenCalledOnce()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
expect(mediaScan).not.toHaveBeenCalled()
await Promise.resolve()
expect(mediaScan).toHaveBeenCalledTimes(1)
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
mediaScan.mock.invocationCallOrder[0]
)
})
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
installErrorClearingHooks(graph)
const node = new LGraphNode('LoadVideo')
node.type = 'LoadVideo'
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
values: []
})
graph.add(node)
await Promise.resolve()
node.isUploading = true
await Promise.resolve()
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
expect(mediaScan).toHaveBeenCalledOnce()
})
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
@@ -543,7 +674,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -611,7 +742,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
describe('realtime verification staleness guards', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
@@ -686,7 +816,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
@@ -771,7 +901,6 @@ describe('realtime verification staleness guards', () => {
describe('scan skips interior of bypassed subgraph containers', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})

View File

@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyCloudMediaCandidates
verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -155,25 +155,26 @@ function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
function scanNodeErrorTargets(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (innerNode.isSubgraphNode?.()) continue
if (isNodeInactive(innerNode.mode)) continue
scanSingleNodeErrors(innerNode)
scanNode(innerNode)
}
return
}
scanSingleNodeErrors(node)
scanNode(node)
}
function scanSingleNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
function getActiveExecutionId(node: LGraphNode): string | null {
if (!app.rootGraph) return null
// Skip when any enclosing subgraph is muted/bypassed. Callers only
// verify each node's own mode; entering a bypassed subgraph (via
// useGraphNodeManager replaying onNodeAdded for existing interior
@@ -181,7 +182,25 @@ function scanSingleNodeErrors(node: LGraphNode): void {
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
return execId
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
scanNodeErrorTargets(node, scanSingleNodeErrors)
}
function scanSingleNodeErrors(node: LGraphNode): void {
scanSingleNodeModelsAndTypes(node)
scanSingleNodeMedia(node)
}
function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
if (!app.rootGraph) return
const execId = getActiveExecutionId(node)
if (!execId) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
@@ -204,39 +223,40 @@ function scanSingleNodeErrors(node: LGraphNode): void {
void verifyAndAddPendingModels(pendingModels)
}
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
function scanSingleNodeMedia(node: LGraphNode): void {
if (!app.rootGraph) return
if (!getActiveExecutionId(node)) return
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
// Cloud media scans return pending for asset verification. OSS scans only
// return pending for generated output media.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
// Check for missing node type
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const execId = getExecutionIdByNode(app.rootGraph, node)
if (execId) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
}
/**
@@ -282,7 +302,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyCloudMediaCandidates(pending)
await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
@@ -293,10 +313,23 @@ async function verifyAndAddPendingMedia(
}
}
function scanAddedNode(node: LGraphNode): void {
function scanAddedNode(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
scanAndAddNodeErrors(node)
scanNodeErrorTargets(node, scanNode)
}
function scheduleAddedNodeScan(node: LGraphNode): void {
queueMicrotask(() => {
scanAddedNode(node, scanSingleNodeModelsAndTypes)
// Paste/drop upload handlers run immediately after graph.add and must set
// node.isUploading synchronously before their first await. This second
// microtask lets that upload state settle before media widgets are scanned.
queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
})
}
function handleNodeModeChange(
@@ -368,10 +401,12 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
// Deferred to microtask because onNodeAdded fires before
// node.configure() restores widget values.
// Model and node scans use the original one-microtask deferral so pasted
// missing-model errors appear before selection-scoped tabs recalculate.
// Media gets one extra microtask so drag/drop upload handlers can mark
// transient upload state before media detection reads the widget value.
if (!ChangeTracker.isLoadingGraph) {
queueMicrotask(() => scanAddedNode(node))
scheduleAddedNodeScan(node)
}
originalOnNodeAdded?.call(this, node)

View File

@@ -33,6 +33,7 @@ export interface MenuOption {
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
isShapePicker?: boolean
}
export interface SubMenuOption {
@@ -124,8 +125,8 @@ export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
canOpenNodeInfo,
openNodeInfo,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
@@ -243,8 +244,8 @@ export function useMoreOptionsMenu() {
options.push({ type: 'divider' })
// Section 4: Node properties (Node Info, Shape, Color)
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
if (canOpenNodeInfo.value) {
options.push(getNodeInfoOption(openNodeInfo))
}
if (groupContext) {
options.push(getGroupColorOptions(groupContext, bump))

View File

@@ -66,6 +66,7 @@ export function useNodeMenuOptions() {
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeSubmenu.value,
isShapePicker: true,
action: () => {}
},
{
@@ -111,10 +112,10 @@ export function useNodeMenuOptions() {
action: runBranch
})
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
const getNodeInfoOption = (openNodeInfo: () => boolean): MenuOption => ({
label: t('contextMenu.Node Info'),
icon: 'icon-[lucide--info]',
action: showNodeHelp
action: openNodeInfo
})
return {

View File

@@ -3,9 +3,11 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
@@ -13,11 +15,6 @@ import {
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
// Mock composables
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
@@ -39,6 +36,45 @@ const mockConnection = {
isNode: false
}
function createMockNodeDef() {
return new ComfyNodeDefImpl({
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
input: {},
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'nodes',
description: ''
})
}
function selectSingleNodeWithNodeDef(id: number) {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id, type: 'TestNode' })
]
vi.mocked(nodeDefStore.fromLGraphNode).mockReturnValue(createMockNodeDef())
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingStore = useSettingStore()
const settingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': false,
...overrides
}
vi.mocked(settingStore.get).mockImplementation(
(key: string): unknown => settingValues[key]
)
}
describe('useSelectionState', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -49,14 +85,7 @@ describe('useSelectionState', () => {
createSpy: vi.fn
})
)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as ReturnType<typeof useNodeLibrarySidebarTab>)
mockSettingValues()
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
@@ -187,4 +216,83 @@ describe('useSelectionState', () => {
expect(newIsPinned).toBe(false)
})
})
describe('Node Info', () => {
test('should open the right side info panel for a selected node', () => {
const rightSidePanelStore = useRightSidePanelStore()
selectSingleNodeWithNodeDef(8)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
openNodeInfo()
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open the right side panel for multiple selected nodes', () => {
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id: 9, type: 'TestNode' }),
createMockLGraphNode({ id: 10, type: 'TestNode' })
]
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
openNodeInfo()
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should open the right side info panel when new menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(11)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
const didOpen = openNodeInfo()
expect(didOpen).toBe(true)
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open node info when legacy menu uses the new node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
selectSingleNodeWithNodeDef(12)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should not open node info when legacy menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(13)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,14 +1,12 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
@@ -25,9 +23,8 @@ export interface NodeSelectionState {
export function useSelectionState() {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { selectedItems } = storeToRefs(canvasStore)
@@ -64,7 +61,7 @@ export function useSelectionState() {
)
const hasAny3DNodeSelected = computed(() => {
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
const enable3DViewer = settingStore.get('Comfy.Load3D.3DViewerEnable')
return (
selectedNodes.value.length === 1 &&
selectedNodes.value.some(isLoad3dNode) &&
@@ -98,34 +95,24 @@ export function useSelectionState() {
const computeSelectionFlags = (): NodeSelectionState =>
computeSelectionStatesFromNodes(selectedNodes.value)
/** Toggle node help sidebar/panel for the single selected node (if any). */
const showNodeHelp = () => {
const def = nodeDef.value
if (!def) return
const canOpenNodeInfo = computed(
() =>
Boolean(nodeDef.value) &&
settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode?.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
return
}
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
nodeHelpStore.openHelp(def)
const openNodeInfo = () => {
if (!canOpenNodeInfo.value) return false
rightSidePanelStore.openPanel('info')
return true
}
return {
selectedItems,
selectedNodes,
nodeDef,
showNodeHelp,
canOpenNodeInfo,
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasSingleSelection,

View File

@@ -233,6 +233,35 @@ describe('handleDrawing', () => {
expect(rafSpy).toHaveBeenCalled()
rafSpy.mockRestore()
})
it('sets DestinationOut composition when tool is eraser during move', async () => {
mockStoreDef.currentTool = 'eraser'
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
const { startDrawing, handleDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
await handleDrawing(makePointerEvent(55, 55))
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
'destination-out'
)
vi.restoreAllMocks()
})
it('sets DestinationOut composition when right mouse button held during move', async () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
const { startDrawing, handleDrawing } = setup()
await startDrawing(makePointerEvent(50, 50))
await handleDrawing(makePointerEvent(55, 55, { buttons: 2 }))
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
'destination-out'
)
vi.restoreAllMocks()
})
})
describe('drawEnd canvas visibility', () => {
@@ -272,6 +301,36 @@ describe('drawEnd', () => {
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false)
})
it('passes isRgb=true to compositeStroke when active layer is rgb', async () => {
mockStoreDef.activeLayer = 'rgb'
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(true, false)
})
it('passes isErasing=true to compositeStroke when tool is eraser', async () => {
mockStoreDef.currentTool = 'eraser'
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, true)
})
it('restores mask canvas opacity after drawing on mask layer', async () => {
mockStoreDef.activeLayer = 'mask'
const mockMaskCanvas = {
width: 200,
height: 200,
style: { opacity: '' }
} as unknown as HTMLCanvasElement
mockStoreDef.maskCanvas = mockMaskCanvas
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))
await drawEnd(makePointerEvent(60, 60))
expect(mockMaskCanvas.style.opacity).toBe(String(mockStoreDef.maskOpacity))
})
it('calls clearPreview to clean up the GPU overlay', async () => {
const { startDrawing, drawEnd } = setup()
await startDrawing(makePointerEvent(50, 50))

View File

@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
})
}
function createFile(name = 'test.png'): File {
return new File(['data'], name, { type: 'image/png' })
function createFile(name = 'test.png', type = 'image/png'): File {
return new File(['data'], name, { type })
}
function successResponse(name: string, subfolder?: string) {
@@ -95,15 +95,21 @@ describe('useNodeImageUpload', () => {
})
})
it('sets isUploading true during upload and false after', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
it.for([
{ mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
{ mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
])(
'sets isUploading true during $mediaType upload and false after',
async ({ filename, mimeType }) => {
mockFetchApi.mockResolvedValueOnce(successResponse(filename))
const promise = capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(true)
const promise = capturedDragOnDrop([createFile(filename, mimeType)])
expect(node.isUploading).toBe(true)
await promise
expect(node.isUploading).toBe(false)
})
await promise
expect(node.isUploading).toBe(false)
}
)
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))

View File

@@ -74,6 +74,11 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn().mockResolvedValue(undefined)
}))
describe('useLoad3d', () => {
let mockLoad3d: Partial<Load3d>
let mockNode: LGraphNode
@@ -181,6 +186,12 @@ describe('useLoad3d', () => {
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
@@ -832,6 +843,7 @@ describe('useLoad3d', () => {
'backgroundImageLoadingEnd',
'modelLoadingStart',
'modelLoadingEnd',
'modelReady',
'skeletonVisibilityChange',
'exportLoadingStart',
'exportLoadingEnd',
@@ -1382,4 +1394,169 @@ describe('useLoad3d', () => {
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
})
})
describe('handleFitToViewer', () => {
it('persists post-fit position and scale into modelConfig.gizmo so reload reapplies the transform via applyGizmoConfigToLoad3d', async () => {
const fitTransform = {
position: { x: 0, y: -1.25, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 0.42, y: 0.42, z: 0.42 }
}
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue(fitTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleFitToViewer()
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
expect(composable.modelConfig.value.gizmo!.position).toEqual(
fitTransform.position
)
expect(composable.modelConfig.value.gizmo!.scale).toEqual(
fitTransform.scale
)
// Rotation is owned by upDirection — fit must not overwrite it.
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
x: 0,
y: 0,
z: 0
})
})
it('is a no-op when load3d is not initialized', () => {
const composable = useLoad3d(mockNode)
// No initializeLoad3d() call.
composable.handleFitToViewer()
expect(mockLoad3d.fitToViewer).not.toHaveBeenCalled()
})
it('does not throw when modelConfig.gizmo is missing', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.modelConfig.value.gizmo = undefined
expect(() => composable.handleFitToViewer()).not.toThrow()
expect(mockLoad3d.fitToViewer).toHaveBeenCalledOnce()
// Without a gizmo slot we silently skip persistence — getGizmoTransform
// is not called because the early-return saves the read.
expect(mockLoad3d.getGizmoTransform).not.toHaveBeenCalled()
})
})
describe('modelReady event handler (thumbnail capture)', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
globalThis.fetch = vi.fn().mockResolvedValue({
blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' }))
} as unknown as Response)
})
afterEach(() => {
globalThis.fetch = originalFetch
})
async function getModelReadyHandler() {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const call = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.find(([event]) => event === 'modelReady')
return { composable, handler: call![1] as () => void }
}
it('registers a modelReady listener separate from modelLoadingEnd', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const events = vi
.mocked(mockLoad3d.addEventListener!)
.mock.calls.map(([event]) => event)
expect(events).toContain('modelReady')
expect(events).toContain('modelLoadingEnd')
expect(composable).toBeDefined()
})
it('does not call captureThumbnail when asset preview is unsupported', async () => {
const { isAssetPreviewSupported } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
const { handler } = await getModelReadyHandler()
handler()
await Promise.resolve()
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
})
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'cube.glb'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
const modelWidget = {
name: 'model_file',
value: 'cube.glb [output]'
} as unknown as IWidget
mockNode.widgets = [modelWidget]
const { handler } = await getModelReadyHandler()
handler()
// Two awaits: one for captureThumbnail, one for fetch().blob() chain.
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
expect(persistThumbnail).toHaveBeenCalledWith(
'cube.glb',
expect.any(Blob)
)
})
it('skips persistence when the model widget has no value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
mockNode.widgets = [
{ name: 'model_file', value: '' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
handler()
await new Promise((r) => setTimeout(r, 0))
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
expect(persistThumbnail).not.toHaveBeenCalled()
})
it('swallows captureThumbnail rejections silently', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'broken.glb'
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
vi.mocked(mockLoad3d.captureThumbnail!).mockRejectedValue(
new Error('webgl context lost')
)
mockNode.widgets = [
{ name: 'model_file', value: 'broken.glb' } as unknown as IWidget
]
const { handler } = await getModelReadyHandler()
expect(() => handler()).not.toThrow()
await new Promise((r) => setTimeout(r, 0))
expect(persistThumbnail).not.toHaveBeenCalled()
})
})
})

View File

@@ -818,24 +818,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
isFirstModelLoad = false
},
modelReady: () => {
if (!load3d || !isAssetPreviewSupported()) return
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
const node = nodeRef.value
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value !== 'string' || !value) return
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value === 'string' && value) {
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
}
}
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
},
skeletonVisibilityChange: (value: boolean) => {
modelConfig.value.showSkeleton = value
@@ -911,9 +911,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const handleFitToViewer = () => {
if (load3d) {
load3d.fitToViewer()
}
if (!load3d) return
load3d.fitToViewer()
if (!modelConfig.value.gizmo) return
const transform = load3d.getGizmoTransform()
modelConfig.value.gizmo.position = transform.position
modelConfig.value.gizmo.scale = transform.scale
}
const handleResetGizmoTransform = () => {

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope } from 'vue'
import type { EffectScope } from 'vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
describe('usePrimeVueOverlayChildStyle', () => {
let scope: EffectScope | undefined
function mountComposable() {
scope = effectScope()
let composable: ReturnType<typeof usePrimeVueOverlayChildStyle> | undefined
scope.run(() => {
composable = usePrimeVueOverlayChildStyle()
})
if (!composable) {
throw new Error('Failed to mount composable')
}
return composable
}
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('preserves existing stacking when there is no PrimeVue parent overlay', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = document.createElement('div')
expect(contentStyle.value).toEqual({})
})
it('renders above the closest PrimeVue dialog mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 5000)
expect(contentStyle.value).toEqual({ zIndex: 5001 })
})
it('renders above the closest PrimeVue overlay mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-overlay-mask', 4200)
expect(contentStyle.value).toEqual({ zIndex: 4201 })
})
it('does not drop below the Reka select overlay z-index floor', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 1200)
expect(contentStyle.value).toEqual({ zIndex: 3000 })
})
it('preserves existing stacking when the PrimeVue overlay z-index is not numeric', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask')
expect(contentStyle.value).toEqual({})
})
})
function appendPrimeVueOverlay(
className: string,
zIndex?: number
): HTMLElement {
const overlay = document.createElement('div')
overlay.className = className
if (zIndex !== undefined) {
overlay.style.zIndex = String(zIndex)
}
const anchor = document.createElement('div')
overlay.append(anchor)
document.body.append(overlay)
return anchor
}

View File

@@ -1,11 +1,14 @@
import { computed } from 'vue'
import type { CSSProperties, ComputedRef } from 'vue'
import { computed, ref } from 'vue'
import type { CSSProperties, ComputedRef, Ref } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
// Matches the highest existing Reka popover z-index (e.g. z-3000 on SearchAutocomplete).
const PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR = 3000
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
@@ -29,3 +32,30 @@ export function usePopoverSizing(
return style
})
}
/**
* Keeps portaled Reka popovers above their containing PrimeVue dialog.
*
* This is a temporary bridge while PrimeVue dialogs and controls are
* incrementally migrated to Reka UI. Once the affected PrimeVue parents are
* migrated, this helper should be removed with the compatibility patch.
*/
export function usePrimeVueOverlayChildStyle(): {
overlayScopeRef: Ref<HTMLElement | null>
contentStyle: ComputedRef<CSSProperties>
} {
const overlayScopeRef = ref<HTMLElement | null>(null)
const contentStyle = computed<CSSProperties>(() => {
const overlay = overlayScopeRef.value?.closest(
'.p-dialog-mask, .p-overlay-mask'
)
if (!overlay) return {}
const zIndex = Number.parseInt(getComputedStyle(overlay).zIndex, 10)
if (!Number.isFinite(zIndex)) return {}
return { zIndex: Math.max(PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR, zIndex + 1) }
})
return { overlayScopeRef, contentStyle }
}

View File

@@ -187,7 +187,8 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn()
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
@@ -250,6 +251,52 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
silentOnNotFound: false
})
})
it('emits modelReady AFTER setCameraState so thumbnail capture sees the restored view', async () => {
const load3d = makeLoad3dMock()
const config = new Load3DConfiguration(load3d)
const cameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective' as const
}
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output',
cameraState: cameraState as unknown as Parameters<
Load3DConfiguration['configure']
>[0]['cameraState']
})
await flush()
const setCameraStateMock = vi.mocked(load3d.setCameraState)
const emitModelReadyMock = vi.mocked(load3d.emitModelReady)
expect(setCameraStateMock).toHaveBeenCalledWith(cameraState)
expect(emitModelReadyMock).toHaveBeenCalledTimes(1)
expect(setCameraStateMock.mock.invocationCallOrder[0]).toBeLessThan(
emitModelReadyMock.mock.invocationCallOrder[0]
)
})
it('emits modelReady even when no saved cameraState is provided', async () => {
const load3d = makeLoad3dMock()
const config = new Load3DConfiguration(load3d)
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
})
it('configureForSaveMesh also emits modelReady once the load resolves', async () => {
const load3d = makeLoad3dMock()
const config = new Load3DConfiguration(load3d)
config.configureForSaveMesh('output', 'model.glb')
await flush()
expect(vi.mocked(load3d.emitModelReady)).toHaveBeenCalledTimes(1)
})
})
describe('parseAnnotatedFilename', () => {

View File

@@ -94,7 +94,7 @@ class Load3DConfiguration {
)
if (filePath) {
onModelWidgetUpdate(filePath)
void onModelWidgetUpdate(filePath)
}
}
@@ -110,7 +110,7 @@ class Load3DConfiguration {
silentOnNotFound
)
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
void onModelWidgetUpdate(modelWidget.value)
}
const originalCallback = modelWidget.callback
@@ -131,7 +131,7 @@ class Load3DConfiguration {
})
modelWidget.callback = (value: string | number | boolean | object) => {
onModelWidgetUpdate(value)
void onModelWidgetUpdate(value)
if (originalCallback) {
originalCallback(value)
@@ -309,6 +309,8 @@ class Load3DConfiguration {
}
isFirstLoad = false
}
this.load3d.emitModelReady()
}
}

View File

@@ -83,6 +83,7 @@ function makeInstance() {
// and ViewHelper, none of which are available in happy-dom. Skip it and
// inject stubs directly onto the prototype instance so delegation methods
// can be exercised in isolation.
const eventManager = { emitEvent: vi.fn() }
const load3d = Object.create(Load3d.prototype) as Load3d
Object.assign(load3d, {
gizmoManager: gizmo,
@@ -92,6 +93,7 @@ function makeInstance() {
controlsManager,
viewHelperManager,
animationManager,
eventManager,
adapterRef: { current: null },
forceRender: vi.fn(),
handleResize: vi.fn()
@@ -106,6 +108,7 @@ function makeInstance() {
controlsManager,
viewHelperManager,
animationManager,
eventManager,
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
}
}
@@ -764,4 +767,86 @@ describe('Load3d', () => {
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
})
})
describe('emitModelReady', () => {
it('emits a modelReady event on the eventManager', () => {
ctx.load3d.emitModelReady()
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
'modelReady',
null
)
})
})
describe('captureThumbnail', () => {
function setupForCapture() {
const cameraStub = {
toggleCamera: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
getCameraState: vi.fn().mockReturnValue({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}),
setCameraState: vi.fn(),
perspectiveCamera: new THREE.PerspectiveCamera()
}
const controlsStub = {
controls: { target: { copy: vi.fn() }, update: vi.fn() }
}
const sceneCaptureMock = vi.fn().mockResolvedValue({
scene: 'data:image/png;base64,scene',
mask: 'm',
normal: 'n'
})
const modelGroup = new THREE.Group()
modelGroup.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)))
Object.assign(ctx.load3d, {
cameraManager: cameraStub,
controlsManager: controlsStub,
sceneManager: {
...ctx.sceneManager,
gridHelper: { visible: true },
captureScene: sceneCaptureMock
},
modelManager: {
...ctx.modelManager,
currentModel: modelGroup
}
})
return { cameraStub, sceneCaptureMock }
}
it('throws when no model is loaded', async () => {
Object.assign(ctx.load3d, {
modelManager: { ...ctx.modelManager, currentModel: null }
})
await expect(ctx.load3d.captureThumbnail()).rejects.toThrow(
'No model loaded for thumbnail capture'
)
})
it('forces a render after restoring camera state so the visible canvas reflects the live scene, not the offscreen capture', async () => {
const { cameraStub } = setupForCapture()
const result = await ctx.load3d.captureThumbnail(64, 64)
expect(result).toBe('data:image/png;base64,scene')
expect(cameraStub.setCameraState).toHaveBeenCalled()
// forceRender must be called AFTER the live state has been restored.
const setCameraOrder = cameraStub.setCameraState.mock.invocationCallOrder
const forceRenderOrder = ctx.forceRender.mock.invocationCallOrder
expect(forceRenderOrder.at(-1)).toBeGreaterThan(setCameraOrder.at(-1)!)
})
it('still forces a render in finally when captureScene rejects', async () => {
const { sceneCaptureMock } = setupForCapture()
sceneCaptureMock.mockRejectedValueOnce(new Error('boom'))
await expect(ctx.load3d.captureThumbnail(64, 64)).rejects.toThrow('boom')
expect(ctx.forceRender).toHaveBeenCalled()
})
})
})

View File

@@ -632,6 +632,10 @@ class Load3d {
this.eventManager.removeEventListener(event, callback)
}
emitModelReady(): void {
this.eventManager.emitEvent('modelReady', null)
}
refreshViewport(): void {
this.handleResize()
}
@@ -812,6 +816,8 @@ class Load3d {
}
this.cameraManager.setCameraState(savedState)
this.controlsManager.controls?.update()
this.forceRender()
}
}

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
const capturedExtensions: ComfyExtension[] = []
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: ComfyExtension) => {
capturedExtensions.push(ext)
}
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
const createdWidgets: MockWidget[] = []
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
}
})
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
})
})

View File

@@ -57,6 +57,7 @@ useExtensionService().registerExtension({
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
@@ -64,8 +65,14 @@ useExtensionService().registerExtension({
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted

View File

@@ -0,0 +1,248 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
const { mockAddAlert, mockApiURL, mockFetchApi, mockRegisterExtension } =
vi.hoisted(() => ({
mockAddAlert: vi.fn(),
mockApiURL: vi.fn((url: string) => `api:${url}`),
mockFetchApi: vi.fn(),
mockRegisterExtension: vi.fn()
}))
let capturedDragDrop: ((files: File[]) => Promise<File[] | never[]>) | undefined
let capturedFileSelect:
| ((files: File[]) => Promise<File[] | never[]>)
| undefined
let capturedPaste: ((files: File[]) => Promise<File[] | never[]>) | undefined
type AudioUploadWidget = (node: LGraphNode, inputName: string) => unknown
vi.mock('extendable-media-recorder', () => ({
MediaRecorder: class MockMediaRecorder {}
}))
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
options: { onDrop: typeof capturedDragDrop }
) => {
capturedDragDrop = options.onDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: (
_node: LGraphNode,
options: { onSelect: typeof capturedFileSelect }
) => {
capturedFileSelect = options.onSelect
return { openFileSelection: vi.fn() }
}
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: (
_node: LGraphNode,
options: { onPaste: typeof capturedPaste }
) => {
capturedPaste = options.onPaste
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: mockAddAlert })
}))
vi.mock('@/renderer/extensions/vueNodes/widgets/utils/audioUtils', () => ({
getResourceURL: (subfolder = '', filename = '', type = 'input') =>
`/view?filename=${filename}&subfolder=${subfolder}&type=${type}`,
splitFilePath: (path: string) => ['', path, 'input']
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: mockApiURL,
fetchApi: mockFetchApi
}
}))
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: mockRegisterExtension,
rootGraph: { id: 'root' }
}
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: vi.fn()
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn()
}))
vi.mock('@/services/audioService', () => ({
useAudioService: () => ({})
}))
function createFile(name = 'clip.mp3'): File {
return new File(['audio'], name, { type: 'audio/mpeg' })
}
function successResponse(name: string, subfolder?: string) {
return {
status: 200,
json: () => Promise.resolve({ name, subfolder })
}
}
function failResponse(status = 500) {
return {
status,
statusText: 'Server Error'
}
}
function createAudioNode() {
const audioWidget = {
name: 'audio',
value: 'previous.mp3',
options: { values: ['previous.mp3'] },
callback: vi.fn()
}
const audioUIWidget = {
name: 'audioUI',
element: document.createElement('audio'),
value: '',
callback: vi.fn()
}
const uploadWidget = { label: '', serialize: true, canvasOnly: false }
const node = fromAny<LGraphNode, unknown>({
widgets: [audioWidget, audioUIWidget],
isUploading: false,
graph: { setDirtyCanvas: vi.fn() },
addWidget: vi.fn(() => uploadWidget),
onWidgetChanged: vi.fn()
})
return { audioUIWidget, audioWidget, node, uploadWidget }
}
async function loadAudioUploadWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.UploadAudio')
if (!extension)
throw new Error('Comfy.UploadAudio extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUploadWidget>).AUDIOUPLOAD
}
describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDragDrop = undefined
capturedFileSelect = undefined
capturedPaste = undefined
})
it('sets isUploading while upload is in progress and clears it after success', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
let resolveUpload: (response: ReturnType<typeof successResponse>) => void
mockFetchApi.mockReturnValueOnce(
new Promise((resolve) => {
resolveUpload = resolve
})
)
const upload = capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(true)
expect(audioWidget.value).toBe('clip.mp3')
resolveUpload!(successResponse('uploaded.mp3', 'pasted'))
await upload
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('pasted/uploaded.mp3')
expect(audioWidget.options.values).toContain('pasted/uploaded.mp3')
expect(node.onWidgetChanged).toHaveBeenCalledWith(
'audio',
'pasted/uploaded.mp3',
'clip.mp3',
audioWidget
)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rejects concurrent audio uploads without starting another request', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
node.isUploading = true
const result = await capturedDragDrop!([createFile()])
expect(result).toEqual([])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('rolls back the widget value and clears isUploading when upload fails', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
mockFetchApi.mockResolvedValueOnce(failResponse())
await capturedPaste!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith('500 - Server Error')
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rolls back the widget value and clears isUploading when upload throws synchronously', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const error = new Error('Upload failed before request promise')
mockFetchApi.mockImplementationOnce(() => {
throw error
})
await capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith(error)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('returns early when no files are provided', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const result = await capturedFileSelect!([])
expect(result).toEqual([])
expect(node.isUploading).toBe(false)
expect(mockFetchApi).not.toHaveBeenCalled()
})
})

View File

@@ -38,6 +38,7 @@ function updateUIWidget(
}
async function uploadFile(
node: LGraphNode,
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
@@ -67,6 +68,7 @@ async function uploadFile(
}
if (updateNode) {
const oldValue = audioWidget.value
updateUIWidget(
audioUIWidget,
api.apiURL(getResourceURL(...splitFilePath(path)))
@@ -75,6 +77,7 @@ async function uploadFile(
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
node.onWidgetChanged?.(audioWidget.name, path, oldValue, audioWidget)
}
return true
} else {
@@ -234,10 +237,19 @@ app.registerExtension({
}
const handleUpload = async (files: File[]) => {
if (files?.length) {
const previousValue = audioWidget.value
audioWidget.value = files[0].name
if (!files?.length) return files
if (node.isUploading) {
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
return []
}
node.isUploading = true
const previousValue = audioWidget.value
audioWidget.value = files[0].name
try {
const success = await uploadFile(
node,
audioWidget,
audioUIWidget,
files[0],
@@ -246,6 +258,9 @@ app.registerExtension({
if (!success) {
audioWidget.value = previousValue
}
} finally {
node.isUploading = false
node.graph?.setDirtyCanvas(true)
}
return files
}

View File

@@ -1,5 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
import type * as I18nModule from './i18n'
let i18n: typeof I18nModule.i18n
let loadLocale: typeof I18nModule.loadLocale
let mergeCustomNodesI18n: typeof I18nModule.mergeCustomNodesI18n
let resolveSupportedLocale: typeof I18nModule.resolveSupportedLocale
let setActiveLocale: typeof I18nModule.setActiveLocale
async function importI18nModule() {
const i18nModule = await import('./i18n')
i18n = i18nModule.i18n
loadLocale = i18nModule.loadLocale
mergeCustomNodesI18n = i18nModule.mergeCustomNodesI18n
resolveSupportedLocale = i18nModule.resolveSupportedLocale
setActiveLocale = i18nModule.setActiveLocale
}
// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
@@ -24,6 +40,7 @@ vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
describe('i18n', () => {
beforeEach(async () => {
vi.resetModules()
await importI18nModule()
})
describe('mergeCustomNodesI18n', () => {
@@ -46,8 +63,6 @@ describe('i18n', () => {
})
it('should store data for not-yet-loaded locales', async () => {
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
// Chinese is not pre-loaded, data should be stored but not merged yet
mergeCustomNodesI18n({
zh: {
@@ -148,7 +163,7 @@ describe('i18n', () => {
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
// Use fresh module instance to ensure clean state
vi.resetModules()
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
await importI18nModule()
mergeCustomNodesI18n({
zh: { plugin1: { name: '插件1' } }
@@ -175,26 +190,88 @@ describe('i18n', () => {
it('should not reload already loaded locale', async () => {
await loadLocale('zh')
await loadLocale('zh')
// Should complete without error (second call returns early)
})
it('should warn for unsupported locale', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await loadLocale('unsupported-locale')
expect(consoleSpy).toHaveBeenCalledWith(
'Locale "unsupported-locale" is not supported'
it('should load shipped BCP-47 variants', async () => {
await loadLocale('zh-TW')
expect(i18n.global.getLocaleMessage('zh-TW')).toEqual(
expect.objectContaining({
commands: expect.any(Object),
nodeDefs: expect.any(Object),
settings: expect.any(Object)
})
)
consoleSpy.mockRestore()
})
it('should handle concurrent load requests for same locale', async () => {
// Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
await Promise.all(promises)
})
})
describe('setActiveLocale', () => {
it('clamps unsupported input to en', async () => {
expect(await setActiveLocale('de')).toBe('en')
expect(i18n.global.locale.value).toBe('en')
})
it('resolves shipped variants and sets the active locale', async () => {
expect(await setActiveLocale('pt-BR')).toBe('pt-BR')
expect(i18n.global.locale.value).toBe('pt-BR')
// pt is not shipped — pt-BR must not be promoted as a base match
expect(await setActiveLocale('pt')).toBe('en')
})
it('honors prioritized navigator.languages', async () => {
// First preference unsupported, second shipped — should land on French.
expect(await setActiveLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
})
})
describe('resolveSupportedLocale', () => {
it('returns the canonical tag when the input is shipped', () => {
expect(resolveSupportedLocale('en')).toBe('en')
expect(resolveSupportedLocale('ja')).toBe('ja')
expect(resolveSupportedLocale('zh-TW')).toBe('zh-TW')
expect(resolveSupportedLocale('pt-BR')).toBe('pt-BR')
})
it('matches case-insensitively per BCP-47 and returns canonical casing', () => {
// Older browsers / OS configs may emit lowercase region tags.
expect(resolveSupportedLocale('pt-br')).toBe('pt-BR')
expect(resolveSupportedLocale('PT-BR')).toBe('pt-BR')
expect(resolveSupportedLocale('zh-tw')).toBe('zh-TW')
expect(resolveSupportedLocale('ZH-TW')).toBe('zh-TW')
expect(resolveSupportedLocale('EN')).toBe('en')
})
it('falls back to the base tag when the full tag is unshipped', () => {
// de-DE → de (unshipped) → en
expect(resolveSupportedLocale('de-DE')).toBe('en')
// fr-CA → fr (shipped) → fr
expect(resolveSupportedLocale('fr-CA')).toBe('fr')
// ko-KR → ko (shipped) → ko
expect(resolveSupportedLocale('ko-KR')).toBe('ko')
// zh-CN → zh (shipped) → zh (Simplified is the base)
expect(resolveSupportedLocale('zh-CN')).toBe('zh')
})
it('falls back to en for unsupported and missing inputs', () => {
expect(resolveSupportedLocale('de')).toBe('en')
expect(resolveSupportedLocale('it')).toBe('en')
expect(resolveSupportedLocale('nl')).toBe('en')
expect(resolveSupportedLocale('xx-YY')).toBe('en')
expect(resolveSupportedLocale('')).toBe('en')
expect(resolveSupportedLocale(undefined)).toBe('en')
expect(resolveSupportedLocale(null)).toBe('en')
})
it('walks a prioritized array per RFC 4647 lookup order', () => {
// First shipped match wins (de unshipped → fr shipped → fr).
expect(resolveSupportedLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
// Empty / all-unshipped arrays fall back to en.
expect(resolveSupportedLocale([])).toBe('en')
expect(resolveSupportedLocale(['de', 'it'])).toBe('en')
})
})
})

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