Compare commits

..

15 Commits

Author SHA1 Message Date
Comfy Org PR Bot
682bd14061 1.46.2 (#12458)
Patch version increment to 1.46.2

**Base branch:** `main`

---------

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

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

## Test plan

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

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

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

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

## Changes

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

## Review Focus

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

---------

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

---

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

## Removed creation entry points

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

## Preserved (intentionally)

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

## Verification

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

## Notes for reviewers

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

## Screenshots

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

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

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots

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

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

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

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

## Changes

- **What**:  Add custom node mappings

## Review Focus

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

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

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

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

## What changed

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

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

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


## Intentional boundaries


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

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

## Follow-up PR plan

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

## Validation

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

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

## Changes

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

## Review Focus

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

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

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

## Changes

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

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

**Base branch:** `main`

---------

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

## Summary

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

## Changes

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

## Review Focus

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


## Video


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

**Base branch:** `main`

---------

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

View File

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

View File

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

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24
25

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

@@ -1,55 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,7 +1,5 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -20,9 +18,7 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
person_profiles: 'identified_only'
})
initialized = true
} catch (error) {

View File

@@ -27,10 +27,6 @@ export class ContextMenu {
await this.waitForHidden()
}
menuItem(name: string): Locator {
return this.anyMenu.getByRole('menuitem', { name, exact: true })
}
/**
* Click a litegraph menu entry. Selects the most recently opened matching
* entry so nested submenu items can be reached without being shadowed by

View File

@@ -1,11 +1,10 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-model-001',
name: 'model.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -17,13 +16,12 @@ function createModelAsset(
}
}
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-input-001',
name: 'input.png',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -34,13 +32,12 @@ function createInputAsset(
}
}
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-output-001',
name: 'output_00001.png',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

View File

@@ -11,11 +11,6 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
type RunOptions = {
nodeErrors?: Record<string, NodeError>
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
}
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
@@ -75,9 +70,8 @@ export class ExecutionHelper {
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(options: RunOptions = {}): Promise<string> {
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
const { nodeErrors = {}, onPromptRequest } = options
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
@@ -87,13 +81,12 @@ export class ExecutionHelper {
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await onPromptRequest?.(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: nodeErrors
node_errors: {}
})
})
fulfilled()

View File

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

View File

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

View File

@@ -135,8 +135,7 @@ export const TestIds = {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset & { hash?: string } = {
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
hash: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset & { hash?: string } = {
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

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

View File

@@ -4,7 +4,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ChangeTrackerDebugState = {
changeCount: number
@@ -311,28 +310,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
]
})
})
test(
'Tracks convert to subgraph as undo step',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
await input.fill('40')
await node.title.click()
await comfyPage.page
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
.click()
await expect(input).toBeHidden()
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('40')
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('512')
}
)
})

View File

@@ -1,60 +1,7 @@
import { mergeTests } from '@playwright/test'
import { expect } from '@playwright/test'
import type { NodeError } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
type PromptRequestNode = {
class_type?: string
}
type PromptRequestBody = {
prompt?: Record<string, PromptRequestNode>
}
function buildPreviewAnyValidationError(): NodeError {
return {
class_type: 'PreviewAny',
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
errors: [
{
type: 'required_input_missing',
message: VALIDATION_ERROR_MESSAGE,
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
function expectPartialExecutionRootNodes(requestBody: unknown): void {
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
}
}
async function getValidationErrorMessage(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(
(nodeId) =>
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
?.message ?? null,
VALIDATION_ERROR_NODE_ID
)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -127,48 +74,3 @@ test.describe(
})
}
)
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
test('preserves validation errors when another active root starts execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const nodeErrors = {
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
}
let promptRequestBody: unknown
const jobId = await exec.run({
nodeErrors,
onPromptRequest: (requestBody) => {
promptRequestBody = requestBody
}
})
expectPartialExecutionRootNodes(promptRequestBody)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await comfyPage.nextFrame()
exec.executionStart(jobId)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
await expect(errorOverlay).toBeVisible()
})
})

View File

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

View File

@@ -166,6 +166,15 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,11 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
const LOTUS_DIFFUSION_MODEL: Asset = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset & { hash?: string } = {
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
hash: outputHash,
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
hash: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset & { hash?: string } = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -1,101 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
/**
* Expanded folder view must drop output records that resolve to the same
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
* v-for in VirtualGrid collides and one asset visibly duplicates its
* neighbours while scrolling.
*/
const STACK_JOB_ID = 'job-output-dedupe'
const COVER_NODE_ID = '9'
const COVER_FILENAME = 'cover_00001_.png'
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
// 4 unique composite keys expected after dedupe.
const STACK_JOB_OUTPUTS = [
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
...DISTINCT_FILENAMES.map((filename) => ({
filename,
subfolder: '',
type: 'output' as const
})),
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
]
const STACK_JOB = createMockJob({
id: STACK_JOB_ID,
create_time: 5000,
execution_start_time: 5000,
execution_end_time: 5050,
preview_output: {
filename: COVER_FILENAME,
subfolder: '',
type: 'output',
nodeId: COVER_NODE_ID,
mediaType: 'images'
},
outputs_count: STACK_JOB_OUTPUTS.length
})
const STACK_JOB_DETAIL: JobDetail = {
...STACK_JOB,
outputs: {
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
}
}
const EXPECTED_TOTAL_TILES = 4
test.describe(
'Expanded folder view dedupes duplicate composite output keys',
{ tag: '@cloud' },
() => {
// @cloud comfyPage already navigates with Firebase auth seeded; a second
// setup() call would clear localStorage and bounce to /cloud/login.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([STACK_JOB])
await comfyPage.assets.mockInputFiles([])
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('renders one tile per unique composite key', async ({
comfyPage
}, testInfo) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
const labels = await tab.assetCards.evaluateAll((nodes) =>
nodes
.map((el) => el.getAttribute('aria-label'))
.filter((v): v is string => v !== null)
)
expect(new Set(labels).size).toBe(labels.length)
await testInfo.attach('expanded-folder-view.png', {
body: await comfyPage.page.screenshot({ fullPage: false }),
contentType: 'image/png'
})
})
}
)

View File

@@ -1,7 +1,8 @@
import { expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -650,12 +651,6 @@ test(
await expect.poll(isConnected).toBe(true)
})
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
@@ -664,6 +659,9 @@ test(
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
@@ -701,18 +699,5 @@ test(
'opacity',
'0'
)
await test.step('Can disconnect link by right click', async () => {
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
const { x, y } = await stepsIOSlot.getPosition()
await comfyPage.page.mouse.click(x, y, { button: 'right' })
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await expect(slotParent).toHaveCSS('opacity', '0')
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const postScreenshot = await comfyPage.page.screenshot({ clip })
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
})
}
)

View File

@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect(nodeRef).toBeBypassed()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -174,33 +174,12 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect(nodeRef).not.toBeBypassed()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
await openContextMenu(comfyPage, nodeTitle)
await expect(bypassItem).toHaveCount(1)
await expect(removeBypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect(nodeRef).toBeBypassed()
await openContextMenu(comfyPage, nodeTitle)
await expect(removeBypassItem).toHaveCount(1)
await expect(bypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect(nodeRef).not.toBeBypassed()
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
@@ -472,7 +451,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect(nodeRef).toBeBypassed()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -481,7 +460,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect(nodeRef).not.toBeBypassed()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
@@ -528,25 +507,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
.toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
await expect
.poll(async () => {
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
return groupNodes.length
})
.toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {

View File

@@ -55,24 +55,4 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
await test.step('Update value of the dynamic combo widget', async () => {
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

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

View File

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

14
global.d.ts vendored
View File

@@ -11,18 +11,6 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -75,8 +63,6 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

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

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@@ -25,7 +25,6 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
@@ -66,9 +65,6 @@
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-canvas: #c2bfb9;
--color-danger-100: #c02323;
--color-danger-200: #d62952;

View File

@@ -1,6 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -1,6 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -7,8 +7,7 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
"./networkUtil": "./src/networkUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -1,35 +0,0 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

View File

@@ -159,7 +159,7 @@
<audio
:ref="(el) => (audioRef = el as HTMLAudioElement)"
:src
:src="audioSrc"
preload="metadata"
class="hidden"
/>
@@ -192,6 +192,7 @@ const progressRef = ref<HTMLElement>()
const {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying,

View File

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

View File

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

View File

@@ -44,14 +44,11 @@
/>
</div>
<div
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
@@ -64,29 +61,25 @@
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
<div
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -149,7 +142,6 @@ const {
isRecording,
isPreview,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -183,7 +175,6 @@ const {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -66,7 +67,6 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
@@ -77,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -88,6 +90,7 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -103,6 +106,13 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -127,16 +137,13 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
if (!node) return

View File

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

View File

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

View File

@@ -65,7 +65,6 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
@@ -156,8 +155,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,7 +189,6 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -322,11 +321,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, this)
}

View File

@@ -39,7 +39,6 @@ import NodePreview from '@/components/node/NodePreview.vue'
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -184,11 +183,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -1,4 +1,3 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -27,20 +26,9 @@ const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -84,7 +72,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: mockToastErrorHandler
toastErrorHandler: vi.fn()
})
}))
@@ -205,46 +193,3 @@ describe('useAuthActions.logout', () => {
)
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('shows the friendly message for a known Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/invalid-credential'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.generic'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('delegates non-Firebase errors to toastErrorHandler', () => {
const { reportError } = useAuthActions()
const networkError = new TypeError('Failed to fetch')
reportError(networkError)
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { st, t } from '@/i18n'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,12 +47,6 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
})
} else {
toastErrorHandler(error)
}

View File

@@ -258,34 +258,6 @@ describe('useSelectedLiteGraphItems', () => {
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
})
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': node1, '1': node2 }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
app.canvas.selected_nodes = { '0': bypassed, '1': active }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('areAllSelectedNodesInMode returns false for empty selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
app.canvas.selected_nodes = {}
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode

View File

@@ -93,22 +93,6 @@ export function useSelectedLiteGraphItems() {
return collectFromNodes(nodeArray)
}
const getSelectedNodesShallow = (): LGraphNode[] =>
Object.values(app.canvas.selected_nodes ?? {})
/**
* True iff every selected node is in `mode`. Mirrors the predicate used by
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
* An empty selection returns `false` (no node is in the mode).
*/
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
const selectedNodeArray = getSelectedNodesShallow()
return (
selectedNodeArray.length > 0 &&
selectedNodeArray.every((node) => node.mode === mode)
)
}
/**
* Toggle the execution mode of all selected nodes
*
@@ -118,10 +102,18 @@ export function useSelectedLiteGraphItems() {
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
const selectedNodeArray = getSelectedNodesShallow()
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
? LGraphEventMode.ALWAYS
: mode
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return
// Convert selected_nodes object to array
const selectedNodeArray: LGraphNode[] = []
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
for (const selectedNode of selectedNodeArray)
selectedNode.mode = newModeForSelectedNode
@@ -134,7 +126,6 @@ export function useSelectedLiteGraphItems() {
hasSelectableItems,
hasMultipleSelectableItems,
getSelectedNodes,
areAllSelectedNodesInMode,
toggleSelectedNodesMode
}
}

View File

@@ -135,51 +135,6 @@ describe('contextMenuConverter', () => {
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
const legacyOptions = convertContextMenuToOptions(
[{ content: 'Bypass', callback: () => {} }],
undefined,
false
)
expect(
legacyOptions.find(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
).toBeUndefined()
const vueBypass: MenuOption = {
label: 'Remove Bypass',
icon: 'icon-[lucide--redo-dot]',
shortcut: 'Ctrl+B',
action: () => {},
source: 'vue'
}
const result = buildStructuredMenu([...legacyOptions, vueBypass])
const bypassItems = result.filter(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
expect(bypassItems).toHaveLength(1)
expect(bypassItems[0].source).toBe('vue')
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
})
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
const options: MenuOption[] = [
{ label: 'Bypass', action: () => {}, source: 'vue' },
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const labels = result
.map((opt) => opt.label)
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
expect(labels).toEqual(
expect.arrayContaining(['Bypass', 'Remove Bypass'])
)
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },

View File

@@ -21,10 +21,7 @@ const HARD_BLACKLIST = new Set([
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)',
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
'Bypass',
'Remove Bypass'
'Copy (Clipspace)'
])
/**
@@ -248,9 +245,7 @@ const MENU_ORDER: string[] = [
'Paste Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
'Paste (Clipspace)'
]
/**

View File

@@ -45,8 +45,7 @@ export interface SubMenuOption {
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
NEW = 'new'
}
// Global singleton for NodeOptions component reference
@@ -212,7 +211,7 @@ export function useMoreOptionsMenu() {
}
if (!groupContext) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(bump)
const bypass = getBypassOption(states, bump)
options.push(pin)
options.push(bypass)
}

View File

@@ -1,97 +0,0 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
const mockApp = vi.hoisted(() => ({
canvas: {
selected_nodes: null as Record<string, LGraphNode> | null
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
const setSelectedNodes = (nodes: LGraphNode[]) => {
const dict: Record<string, LGraphNode> = {}
nodes.forEach((n, i) => {
dict[String(i)] = n
})
mockApp.canvas.selected_nodes = dict
}
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
({ id, mode }) as LGraphNode
const getBypassLabel = (): string => {
let label = ''
const Wrapper = defineComponent({
setup() {
const { getBypassOption } = useNodeMenuOptions()
label = getBypassOption(() => {}).label ?? ''
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockApp.canvas.selected_nodes = null
})
it('labels as "Bypass" when no node is bypassed', () => {
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
})
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.BYPASS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
})
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.ALWAYS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
})
})

View File

@@ -1,9 +1,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
@@ -23,7 +20,6 @@ export function useNodeMenuOptions() {
toggleNodeBypass,
runBranch
} = useSelectedNodeActions()
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const shapeSubmenu = computed(() =>
shapeOptions.map((shape) => ({
@@ -95,8 +91,11 @@ export function useNodeMenuOptions() {
}
})
const getBypassOption = (bump: () => void): MenuOption => ({
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
const getBypassOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.bypassed
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: 'icon-[lucide--redo-dot]',

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } 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'
@@ -13,6 +13,7 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
collapsed: boolean
pinned: boolean
bypassed: boolean
}
/**
@@ -77,10 +78,12 @@ export function useSelectionState() {
const computeSelectionStatesFromNodes = (
nodes: LGraphNode[]
): NodeSelectionState => {
if (!nodes.length) return { collapsed: false, pinned: false }
if (!nodes.length)
return { collapsed: false, pinned: false, bypassed: false }
return {
collapsed: nodes.some((n) => n.flags?.collapsed),
pinned: nodes.some((n) => n.pinned)
pinned: nodes.some((n) => n.pinned),
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
}
}

View File

@@ -1,6 +1,5 @@
import { ref, shallowRef } from 'vue'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -38,8 +37,7 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
const nodeDef = draggedNode.value
if (!nodeDef) return false
if (!draggedNode.value) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -48,9 +46,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
if (node) canvas.selectItems([node])
return true
}

View File

@@ -8,7 +8,6 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
@@ -147,11 +146,9 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
if (!node) return

View File

@@ -4,7 +4,6 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app as comfyApp } from '@/scripts/app'
@@ -38,9 +37,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -61,8 +58,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}

View File

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

View File

@@ -192,7 +192,6 @@ describe('useLoad3d', () => {
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
@@ -1355,39 +1354,6 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
const modelTransform = {
position: { x: 5, y: 6, z: 7 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 3, y: 3, z: 3 }
}
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const handler = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
await nextTick()
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
const savedScene = mockNode.properties['Scene Config'] as {
models: unknown[]
}
expect(savedScene.models).toEqual([modelTransform])
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -132,7 +132,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
@@ -790,11 +789,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const syncSceneModels = () => {
const modelInfo = load3d?.getModelInfo()
sceneConfig.value.models = modelInfo ? [modelInfo] : []
}
const eventConfig = {
materialModeChange: (value: string) => {
modelConfig.value.materialMode = value as MaterialMode
@@ -854,7 +848,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
canUseGizmo.value = caps?.gizmoTransform ?? true
@@ -867,7 +860,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
]
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
syncSceneModels()
isFirstModelLoad = false
},
modelReady: () => {
@@ -944,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
syncSceneModels()
}
} as const
@@ -970,11 +961,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const transform = load3d.getGizmoTransform()
modelConfig.value.gizmo.position = transform.position
modelConfig.value.gizmo.scale = transform.scale
syncSceneModels()
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
}
const handleResetGizmoTransform = () => {
@@ -1017,7 +1003,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel,
isPlyModel,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -1053,7 +1038,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
}
}

View File

@@ -4,7 +4,6 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -369,7 +368,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
isPreview.value = node.type === 'Preview3D'
if (sceneConfig) {
backgroundColor.value =

View File

@@ -108,6 +108,23 @@ describe('useWaveAudioPlayer', () => {
expect(bars.value).toHaveLength(10)
})
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
mockFetchApi.mockRejectedValue(new Error('Network error'))
const src = ref('/api/view?filename=audio.wav&type=output')
const { bars, loading, audioSrc } = useWaveAudioPlayer({
src,
barCount: 10
})
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(bars.value).toHaveLength(10)
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
})
it('does not call decodeAudioSource when src is empty', () => {
const src = ref('')
useWaveAudioPlayer({ src })

View File

@@ -1,5 +1,5 @@
import { useMediaControls, whenever } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import { api } from '@/scripts/api'
@@ -19,6 +19,7 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
const audioRef = ref<HTMLAudioElement>()
const waveformRef = ref<HTMLElement>()
const blobUrl = ref<string>()
const loading = ref(false)
let decodeRequestId = 0
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
@@ -34,6 +35,10 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedDuration = computed(() => formatTime(duration.value))
const audioSrc = computed(() =>
src.value ? (blobUrl.value ?? src.value) : ''
)
function generatePlaceholderBars(): WaveformBar[] {
return Array.from({ length: barCount }, () => ({
height: Math.random() * 60 + 10
@@ -85,12 +90,22 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
if (requestId !== decodeRequestId) return
const blob = new Blob([arrayBuffer.slice(0)], {
type: response.headers.get('content-type') ?? 'audio/wav'
})
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
blobUrl.value = URL.createObjectURL(blob)
ctx = new AudioContext()
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
if (requestId !== decodeRequestId) return
generateBarsFromBuffer(audioBuffer)
} catch {
if (requestId === decodeRequestId) {
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
bars.value = generatePlaceholderBars()
}
} finally {
@@ -158,9 +173,19 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
{ immediate: true }
)
onUnmounted(() => {
decodeRequestId += 1
audioRef.value?.pause()
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
})
return {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying: playing,

View File

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

View File

@@ -1833,38 +1833,6 @@ const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
const convertDisabled = (selected: LGraphNode[]) =>
selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n))
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
@@ -1900,13 +1868,6 @@ let globalDefs: Record<string, ComfyNodeDef>
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: () => convertSelectedNodesToGroupNode()
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
@@ -1924,13 +1885,6 @@ const ext: ComfyExtension = {
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
@@ -1942,42 +1896,13 @@ const ext: ComfyExtension = {
],
getCanvasMenuItems(canvas): IContextMenuValue[] {
const items: IContextMenuValue[] = []
const selected = Object.values(canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
items.push({
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
})
const groups = canvas.graph?.extra?.groupNodes
const manageDisabled = !groups || !Object.keys(groups).length
items.push({
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
})
return items
},
getNodeMenuItems(node): IContextMenuValue[] {
if (GroupNodeHandler.isGroupNode(node)) {
return []
}
const selected = Object.values(app.canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
return [
{
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
}
]
},

View File

@@ -164,7 +164,6 @@ function makeLoad3DNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
size: [300, 600],
setSize: vi.fn(),
addWidget: vi.fn(),
widgets: overrides.widgets ?? [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },

View File

@@ -6,8 +6,7 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState,
Model3DInfo
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
@@ -219,15 +218,13 @@ useExtensionService().registerExtension({
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
name: 'Point Cloud Engine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
tooltip:
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
type: 'combo',
options: ['threejs', 'fastply'],
options: ['threejs', 'fastply', 'sparkjs'],
defaultValue: 'threejs',
migrateDeprecatedValue: (value) =>
value === 'sparkjs' ? 'threejs' : value,
experimental: true
}
],
@@ -269,45 +266,41 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
if (node.constructor.comfyClass === 'Load3D') {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
)
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
const widget = new ComponentWidgetImpl({
node: node,
name: 'image',
@@ -409,9 +402,6 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
@@ -419,8 +409,7 @@ useExtensionService().registerExtension({
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: '',
model_3d_info
recording: ''
}
const recordingData = currentLoad3d.getRecordingData()

View File

@@ -162,42 +162,6 @@ describe('CameraManager', () => {
const snapshot = manager.getCameraState()
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
})
it('captures the active camera orientation as a serializable quaternion', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { quaternion } = manager.getCameraState()
expect(quaternion).toEqual({
x: manager.perspectiveCamera.quaternion.x,
y: manager.perspectiveCamera.quaternion.y,
z: manager.perspectiveCamera.quaternion.z,
w: manager.perspectiveCamera.quaternion.w
})
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
})
it('captures the configured perspective fov regardless of active camera', () => {
manager.perspectiveCamera.fov = 42
manager.toggleCamera('orthographic')
expect(manager.getCameraState().fov).toBe(42)
})
it('reflects the perspective aspect after a resize', () => {
manager.handleResize(800, 400)
expect(manager.getCameraState().aspect).toBe(2)
})
it('reflects the orthographic frustum bounds after a resize', () => {
manager.toggleCamera('orthographic')
manager.handleResize(800, 400)
const { frustum } = manager.getCameraState()
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
})
})
describe('setControls', () => {

View File

@@ -144,10 +144,6 @@ export class CameraManager implements CameraManagerInterface {
}
getCameraState(): CameraState {
const { x, y, z, w } = this.activeCamera.quaternion
const activeCamera = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
return {
position: this.activeCamera.position.clone(),
target: this.controls?.target.clone() || new THREE.Vector3(),
@@ -155,18 +151,7 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: this.getCurrentCameraType(),
quaternion: { x, y, z, w },
fov: this.perspectiveCamera.fov,
aspect: this.perspectiveCamera.aspect,
near: activeCamera.near,
far: activeCamera.far,
frustum: {
left: this.orthographicCamera.left,
right: this.orthographicCamera.right,
top: this.orthographicCamera.top,
bottom: this.orthographicCamera.bottom
}
cameraType: this.getCurrentCameraType()
}
}

View File

@@ -314,30 +314,6 @@ describe('GizmoManager', () => {
})
})
describe('getModelInfo', () => {
it('returns the full transform payload for the target object', () => {
manager.init()
const model = new THREE.Object3D()
model.name = 'my-model'
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const info = manager.getModelInfo()
expect(info).not.toBeNull()
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
})
it('returns null when there is no target', () => {
manager.init()
expect(manager.getModelInfo()).toBeNull()
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()

View File

@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode, Model3DTransform } from './interfaces'
import type { GizmoMode } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -215,30 +215,6 @@ export class GizmoManager {
}
}
getModelInfo(): Model3DTransform | null {
const object = this.targetObject
if (!object) return null
return {
position: {
x: object.position.x,
y: object.position.y,
z: object.position.z
},
quaternion: {
x: object.quaternion.x,
y: object.quaternion.y,
z: object.quaternion.z,
w: object.quaternion.w
},
scale: {
x: object.scale.x,
y: object.scale.y,
z: object.scale.z
}
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()

View File

@@ -25,7 +25,6 @@ import type {
Load3DOptions,
LoadModelOptions,
MaterialMode,
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
@@ -161,23 +160,11 @@ class Load3d {
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
}
private async repaintWhenSparkPaintable(): Promise<void> {
const sortComplete = this.sceneManager.awaitNextSparkDirty()
this.forceRender()
await sortComplete
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
@@ -638,7 +625,7 @@ class Load3d {
}
getCurrentModelCapabilities(): ModelAdapterCapabilities {
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
}
clearModel(): void {
@@ -927,31 +914,11 @@ class Load3d {
return this.gizmoManager.getTransform()
}
public getModelInfo(): Model3DTransform | null {
return this.gizmoManager.getModelInfo()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()
}
public centerCameraOnModel(): void {
const bounds = this.modelManager.getCurrentBounds()
if (!bounds || bounds.isEmpty()) return
const center = bounds.getCenter(new THREE.Vector3())
const camera = this.cameraManager.activeCamera
const controls = this.controlsManager.controls
const offset = center.clone().sub(camera.position)
camera.position.add(offset)
controls.target.add(offset)
camera.updateMatrixWorld(true)
controls.update()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()

View File

@@ -7,11 +7,7 @@ import type {
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
function makeEventManagerStub() {
return {
@@ -32,12 +28,6 @@ type ModelManagerStub = {
originalURL: string | null
}
const STUB_CAPS = {} as ModelAdapterCapabilities
const loadResult = (object: THREE.Object3D) => ({
object,
capabilities: STUB_CAPS
})
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
@@ -51,21 +41,14 @@ function makeModelManagerStub(): ModelManagerStub {
}
}
const {
meshLoad,
splatLoad,
pointCloudLoad,
fetchModelDataMock,
isGaussianSplatPLYMock,
addAlert
} = vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
addAlert: vi.fn()
}))
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
@@ -82,35 +65,19 @@ vi.mock('./PointCloudModelAdapter', () => ({
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
}
},
getPLYEngine: () => getPLYEngineMock()
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities = {}
matches = async (
ext: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> => {
if (ext !== 'ply') return true
return isGaussianSplatPLYMock(await fetchBytes())
}
load = splatLoad
}
}))
vi.mock('./ModelAdapter', async () => {
const actual =
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
return { ...actual, fetchModelData: fetchModelDataMock }
})
vi.mock('@/scripts/metadata/ply', () => ({
isGaussianSplatPLY: isGaussianSplatPLYMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
@@ -120,10 +87,7 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
type LoaderManagerInternals = {
pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null>
pickAdapter(extension: string): ModelAdapter | null
}
function makeLoaderManager() {
@@ -134,21 +98,21 @@ function makeLoaderManager() {
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
const pick = (ext: string) =>
internals.pickAdapter.call(lm, ext, () =>
fetchModelDataMock()
) as Promise<ModelAdapter | null>
return { lm, modelManager, eventManager, pick }
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
isGaussianSplatPLYMock.mockResolvedValue(false)
})
describe('getCurrentAdapter', () => {
@@ -159,7 +123,7 @@ describe('LoaderManager', () => {
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
@@ -168,7 +132,7 @@ describe('LoaderManager', () => {
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -180,7 +144,7 @@ describe('LoaderManager', () => {
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -231,10 +195,7 @@ describe('LoaderManager', () => {
}
let adapterDuringClear: ModelAdapter | null | undefined
const adapterRef = {
current: oldAdapter as ModelAdapter | null,
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
}
const adapterRef = { current: oldAdapter as ModelAdapter | null }
const lm = new LoaderManager(
modelManager,
eventManager,
@@ -262,8 +223,8 @@ describe('LoaderManager', () => {
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
resolveSplatLoad = resolve
})
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
splatLoad.mockReturnValueOnce(slowSplatLoad)
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
const aPromise = lm.loadModel('api/view?filename=a.splat')
@@ -282,36 +243,42 @@ describe('LoaderManager', () => {
describe('pickAdapter', () => {
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
async (ext) => {
(ext) => {
const { pick } = makeLoaderManager()
expect((await pick(ext))?.kind).toBe('mesh')
expect(pick(ext)?.kind).toBe('mesh')
}
)
it.for(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
async (ext) => {
(ext) => {
const { pick } = makeLoaderManager()
expect((await pick(ext))?.kind).toBe('splat')
expect(pick(ext)?.kind).toBe('splat')
}
)
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
const { pick } = makeLoaderManager()
expect((await pick('ply'))?.kind).toBe('splat')
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
const { pick } = makeLoaderManager()
expect((await pick('ply'))?.kind).toBe('pointCloud')
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('returns null for unknown extensions', async () => {
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { pick } = makeLoaderManager()
expect(await pick('xyz')).toBeNull()
expect(await pick('')).toBeNull()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
})
})
@@ -381,7 +348,7 @@ describe('LoaderManager', () => {
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loadResult(loaded))
meshLoad.mockResolvedValueOnce(loaded)
await lm.loadModel('api/view?filename=cube.glb')
@@ -399,7 +366,7 @@ describe('LoaderManager', () => {
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
@@ -411,7 +378,7 @@ describe('LoaderManager', () => {
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
@@ -423,105 +390,32 @@ describe('LoaderManager', () => {
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb',
expect.any(Function)
'cube.glb'
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb',
expect.any(Function)
'cube.glb'
)
})
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { lm } = makeLoaderManager()
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
})
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
const buf = new ArrayBuffer(16)
fetchModelDataMock.mockResolvedValueOnce(buf)
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
// Adapter receives a fetchBytes function (memoized), not bytes directly.
expect(splatLoad).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
'scan.ply',
expect.any(Function)
)
// matches() called fetchBytes once; load()'s call hit the cached promise.
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
})
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
const modelManager =
makeModelManagerStub() as unknown as ConstructorParameters<
typeof LoaderManager
>[0]
const eventManager = makeEventManagerStub()
// A splat adapter that ALSO claims '.ply' and is listed first.
// Without matches(), it would short-circuit. With matches() returning
// false (not a 3DGS PLY), the dispatcher must skip to the next
// candidate (point cloud).
const splatAdapter = {
kind: 'splat' as const,
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
capabilities: {} as never,
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
load: splatLoad
}
const pointCloudAdapter = {
kind: 'pointCloud' as const,
extensions: ['ply'] as const,
capabilities: {} as never,
load: pointCloudLoad
}
const lm = new LoaderManager(modelManager, eventManager, [
splatAdapter,
pointCloudAdapter
])
isGaussianSplatPLYMock.mockResolvedValue(false)
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
@@ -604,8 +498,8 @@ describe('LoaderManager', () => {
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad.then(loadResult))
.mockResolvedValueOnce(loadResult(secondModel))
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')

View File

@@ -4,14 +4,9 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import { createAdapterRef, fetchModelData } from './ModelAdapter'
import type {
AdapterRef,
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
@@ -41,16 +36,14 @@ function isNotFoundError(error: unknown): boolean {
}
/**
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
* file extensions it owns. For shared extensions (.ply), the adapter with an
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new SplatModelAdapter(),
new PointCloudModelAdapter()
new PointCloudModelAdapter(),
new SplatModelAdapter()
]
}
@@ -93,7 +86,6 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.adapterRef.current = null
this.adapterRef.capabilities = null
this.modelManager.originalURL = url
@@ -130,8 +122,7 @@ export class LoaderManager implements LoaderManagerInterface {
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
this.adapterRef.capabilities = result.capabilities
await this.modelManager.setupModel(result.object)
await this.modelManager.setupModel(result.model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
@@ -146,18 +137,19 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private async pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null> {
const candidates = this.adapters.filter((a) =>
a.extensions.includes(extension)
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
)
for (const adapter of candidates) {
if (!adapter.matches) return adapter
if (await adapter.matches(extension, fetchBytes)) return adapter
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
}
return null
return match
}
private createLoadContext(): ModelLoadContext {
@@ -178,11 +170,7 @@ export class LoaderManager implements LoaderManagerInterface {
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{
object: THREE.Object3D
adapter: ModelAdapter
capabilities: ModelAdapterCapabilities
} | null> {
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
@@ -200,24 +188,10 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
let bytesPromise: Promise<ArrayBuffer> | null = null
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
const adapter = this.pickAdapter(fileExtension)
if (!adapter) return null
const loadResult = await adapter.load(
this.createLoadContext(),
path,
filename,
fetchBytes
)
return loadResult
? {
object: loadResult.object,
capabilities: loadResult.capabilities,
adapter
}
: null
const model = await adapter.load(this.createLoadContext(), path, filename)
return model ? { model, adapter } : null
}
}

View File

@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result!.object).toBe(fbxModel)
expect(result).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
'cube.obj'
)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result!.object).toBe(scene)
expect(result).toBe(scene)
})
it('also handles .gltf filenames', async () => {

View File

@@ -11,8 +11,7 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext,
ModelLoadResult
ModelLoadContext
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
@@ -46,18 +45,20 @@ export class MeshModelAdapter implements ModelAdapter {
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<ModelLoadResult | null> {
): Promise<THREE.Object3D | null> {
const extension = filename.split('.').pop()?.toLowerCase()
const object = await (extension === 'stl'
? this.loadSTL(ctx, path, filename)
: extension === 'fbx'
? this.loadFBX(ctx, path, filename)
: extension === 'obj'
? this.loadOBJ(ctx, path, filename)
: extension === 'gltf' || extension === 'glb'
? this.loadGLTF(ctx, path, filename)
: Promise.resolve(null))
return object ? { object, capabilities: this.capabilities } : null
switch (extension) {
case 'stl':
return this.loadSTL(ctx, path, filename)
case 'fbx':
return this.loadFBX(ctx, path, filename)
case 'obj':
return this.loadOBJ(ctx, path, filename)
case 'gltf':
case 'glb':
return this.loadGLTF(ctx, path, filename)
}
return null
}
private async loadSTL(

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**
@@ -65,59 +65,24 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
}
/**
* Result returned by `ModelAdapter.load()`. Capabilities ride with the model
* because some adapters (notably PLY) produce different capability sets
* depending on the file contents — face-less point clouds expose only the
* 'pointCloud' material mode, indexed meshes expose the full set. Keeping
* capabilities per-load (not per-adapter) prevents stale state on the
* adapter instance between two successive loads.
* Mutable handle to the currently active ModelAdapter. A single ref is
* created in `createLoad3d` and shared between LoaderManager (writer) and
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
* don't depend on construction order between those collaborators.
*/
export type ModelLoadResult = {
object: THREE.Object3D
capabilities: ModelAdapterCapabilities
}
export type AdapterRef = { current: ModelAdapter | null }
/**
* Mutable handle to the currently active ModelAdapter plus the capabilities
* reported by its most recent load. A single ref is created in `createLoad3d`
* and shared between LoaderManager (writer) and SceneModelManager + Load3d
* (readers), so capability/bounds/dispose lookups don't depend on
* construction order between those collaborators.
*/
export type AdapterRef = {
current: ModelAdapter | null
capabilities: ModelAdapterCapabilities | null
}
export const createAdapterRef = (): AdapterRef => ({
current: null,
capabilities: null
})
export const createAdapterRef = (): AdapterRef => ({ current: null })
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
/**
* Default capabilities for this adapter family. `load()` may return a
* narrowed set for a specific model — read `adapterRef.capabilities` for
* the live per-model value rather than this.
*/
readonly capabilities: ModelAdapterCapabilities
/**
* Async tiebreaker when multiple adapters claim the same extension
* (e.g. .ply is shared by Gaussian splats and classic point clouds).
* Adapters that uniquely own their extensions can omit this.
*/
matches?(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean>
load(
ctx: ModelLoadContext,
path: string,
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult | null>
filename: string
): Promise<THREE.Object3D | null>
/**
* Optional. Return a world-space AABB for the given model. Adapters for
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.

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