Compare commits

..

33 Commits

Author SHA1 Message Date
dante01yoon
e994c2f630 Revert "fix: update knip package entrypoints"
This reverts commit 711fa84e3c.
2026-04-28 16:23:08 +09:00
dante01yoon
711fa84e3c fix: update knip package entrypoints 2026-04-28 16:00:51 +09:00
dante01yoon
51874a57eb refactor: address promotion policy review 2026-04-28 15:57:27 +09:00
Alexander Brown
5782131a18 Merge branch 'main' into refactor/extract-widget-classification 2026-04-27 19:17:49 -07:00
Terry Jia
f001bc9af3 feat: derive Preview3D camera pose from EXTRINSICS/INTRINSICS matrices (#11626)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
splat workflows (and PLY pipelines that go through SHARP / COLMAP) emit
camera pose as extrinsics + intrinsics matrices, and the viewer needs a
way to consume them before that end-to-end story can ship.

## Summary

Adds the ability to drive the Preview3D camera from a pair of
OpenCV-convention extrinsics + intrinsics matrices (as produced by SHARP
/ COLMAP / other SfM pipelines), so backend nodes that emit such
matrices can position the viewer's camera deterministically. Fifth in
the series splitting up
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. Fully
self-contained — adds new API surface only, no existing call paths are
modified.

## Changes

- **What**:
- `cameraFromMatrices.ts` (new): pure function
`computeCameraFromMatrices(extrinsics, intrinsics)` returning `{
position, target, fovYDegrees }`. No THREE.js dependency.
Shape-validates the inputs (4×4 and 3×3) with a clear error.
- `Load3d.setCameraFromMatrices(extrinsics, intrinsics)`: wires the
utility into Load3d via `setCameraState` + `setFOV`. Preserves the
caller's existing `zoom` and `cameraType`.
- `load3d.ts` (extension): Preview3D's `onExecuted` extracts
`extrinsics`/`intrinsics` from `result[3]`/`result[4]` (when present)
and calls `setCameraFromMatrices`. The output tuple type is widened to
include the two optional `Matrix` fields.

## Review Focus

- **Convention conversion**: OpenCV is Y-down, Z-forward; three.js is
Y-up, Z-back. The function flips Y and Z on both `position` and `target`
(equivalent to a 180° X-axis rotation of the whole world). The same
rotation is applied elsewhere to splats at load time, so a future splat
+ camera-pose pair lines up.
- **FOV math**: vertical FOV (radians) = `2 * atan(cy / fy)`. We expose
it in degrees, matching `cameraManager.setFOV`'s contract.
- **Camera-state preservation**: `setCameraFromMatrices` reads the
current state to keep `zoom` and `cameraType` intact — only `position`,
`target`, and `fov` come from the matrices.
- **Backwards compatibility**: backend nodes that don't return matrices
simply skip the new branch (the `if (extrinsics && intrinsics)` guard).
Existing Preview3D workflows are unaffected.
- 7 unit tests for the matrix math (identity, rotation, translation, FOV
derivation, shape errors, etc.); 1 unit test for the Load3d wiring
(verifies the destructured `position`/`target`/`fovYDegrees` reach
`setCameraState` + `setFOV` with `zoom`/`cameraType` preserved).

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `cameraFromMatrices.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | ~7.6% | 0% | ~14.6% | ~7.7% |
| `load3d.ts` (extension, modified) | 0% | 0% | 0% | 0% |

The `Load3d.ts` numbers are the pre-existing baseline — `Load3d.test.ts`
covers façade methods via prototype injection rather than instantiating
the class (the constructor needs `THREE.WebGLRenderer`, which happy-dom
can't provide). The new `setCameraFromMatrices` method body is exercised
by a new unit test that asserts `setCameraState` receives the
destructured matrix output and `setFOV` receives the computed
`fovYDegrees`, with the caller's `zoom`/`cameraType` preserved.

`load3d.ts` (the extension registration file, 0% on `main` and after
this PR) has no unit-test scaffolding in the project — its `onExecuted`
handler runs only after a workflow execution and is exercised end-to-end
via browser tests. The new `if (extrinsics && intrinsics)
load3d.setCameraFromMatrices(...)` branch sits in that path.

Net: the matrix math itself, which previously didn't exist, is now 100%
covered. The wiring layer relies on the same e2e safety net the
surrounding code has always used.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11626-feat-derive-Preview3D-camera-pose-from-EXTRINSICS-INTRINSICS-matrices-34d6d73d3650817297bdf25a83a4f7a2)
by [Unito](https://www.unito.io)
2026-04-27 21:58:12 -04:00
Kelly Yang
88faaf3d86 test: complete remaining Painter widget E2E tests (#11613)
## Summary

Implemented E2E test coverage for Levels 6-12 of the Painter Widget

## Changes

Adds the following coverage to complete the test plan:

Level 6 - Input image connection (3 tests, @slow):
  - Width/height/bg-color controls hide when input is linked
  - Canvas resizes to match input image dimensions after execution
  - Drawing over input image produces canvas content

Level 7 - Clear on empty canvas is harmless

Level 8 - Unchanged canvas does not re-upload on second serialization

Level 9 - Settings persistence:
  - Tool selection saved to node.properties.painterTool
  - Brush size change saved to node.properties.painterBrushSize

Level 10 - Compact layout collapses to grid-cols-1 when node width <
350px

Level 12 - Rapid drawing accumulates all strokes (checks 3 y-positions)

Supporting changes:
- Add data-testid="painter-controls" to controls grid in
WidgetPainter.vue (needed for compact mode class assertion)
- Add browser_tests/assets/widgets/painter_with_input.json workflow
fixture (LoadImage connected to Painter input slot 0)



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly adds/adjusts Playwright and unit test coverage; the only
runtime change is wrapping pointer-capture calls in `try/catch`, which
is low-risk but touches input-handling paths.
> 
> **Overview**
> Completes and expands Painter widget browser test coverage, including
new scenarios for clearing an empty canvas, preventing redundant uploads
when serializing an unchanged canvas, persisting tool/brush-size
settings to node properties, compact layout behavior, multi-stroke
accumulation checks, and an input-image-connected workflow (new
`painter_with_input.json`) with execution/resizing/draw-over-image
assertions.
> 
> Hardens `usePainter` pointer handling by tolerating
`setPointerCapture`/`releasePointerCapture` failures (e.g., synthetic
events), with corresponding unit tests updated/added to validate the
behavior and serialization expectations.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
056d4a9f0c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11613-test-complete-remaining-Painter-widget-E2E-tests-34c6d73d36508158b620f55aa1981cf5)
by [Unito](https://www.unito.io)
2026-04-27 21:41:45 -04:00
Terry Jia
42ff7b6c62 refactor(load3d): drive viewer behavior from ModelAdapter capabilities (#11660)
> Final architectural step in the PLY / 3D Gaussian Splatting series.
Previous PR introduced `ModelAdapter` with a dormant `capabilities`
field; this PR makes those capabilities load-bearing and replaces the
remaining `instanceof SplatMesh` / `instanceof BufferGeometry` switches
with adapter-driven dispatch. Together with previous one it removes the
last of the format-specific branching from `SceneModelManager` /
`Load3d`. Seventh in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Summary

Drive viewer behavior (fit-to-viewer, default camera pose, world bounds,
GPU dispose, material rebuild) from `ModelAdapterCapabilities` + 3 new
optional adapter methods, instead of `SceneModelManager` reflecting on
model shape. `Load3d` is rewritten to take its 13 managers as injected
`Load3dDeps`; a new `createLoad3d` factory assembles them and threads a
single `AdapterRef` between `LoaderManager` (writer) and
`SceneModelManager` + `Load3d` (readers). Splat orientation +
decoder-race + sizing bugs are fixed as a side effect — splats now
render upright, fill the grid, and don't lock the OrbitControls target
on first frame.

## Changes

- **`ModelAdapter.ts`**: add `AdapterRef = { current: ModelAdapter |
null }` shared handle and 3 optional adapter methods —
`computeBounds(model)`, `disposeModel(model)`, `defaultCameraPose()`.
- **`SplatModelAdapter.ts`**: implement all 3 optional methods; `await
splatMesh.initialized` so first-frame bounds are populated (fixes a
decoder race that collapsed the OrbitControls target onto the camera);
`quaternion.set(1, 0, 0, 0)` to convert sparkjs's OpenCV (Y-down,
Z-forward) to three.js (Y-up, Z-back); flip `fitToViewer` back to `true`
and bump `fitTargetSize` to `20` so splats fill the 20-unit grid instead
of shrinking to 1/4 of it.
- **`PointCloudModelAdapter.ts`**: extract
`buildPointCloudForMaterialMode` so `SceneModelManager` rebuilds via the
same code path the initial load uses; `setPath` so PLYs that reference
relative assets resolve correctly.
- **`LoaderManager.ts`**: accept optional `AdapterRef`; write through it
instead of the internal `_currentAdapter` field. `clearModel()` now runs
while the old adapter is still current so its `disposeModel()` can
release renderer-owned resources.
- **`SceneModelManager.ts`**: accept 4 capability lambdas
(`getCurrentCapabilities` / `getBoundsFromAdapter` /
`disposeModelViaAdapter` / `getDefaultCameraPose`) with
`DEFAULT_MODEL_CAPABILITIES` / null fallbacks. `setupModel`,
`fitToViewer`, and material-mode rebuild are now capability-driven;
`containsSplatMesh` (30 lines of `instanceof` traversal) and
`handlePLYModeSwitch` (90 lines of duplicated PLY rebuild) are gone.
- **`Load3d.ts`**: ctor switches from manager-creation to deps-injected
(`Load3dDeps`); add `getCurrentModelCapabilities()` reader; gate
`setGizmoEnabled` / `setGizmoMode` / `resetGizmoTransform` /
`applyGizmoTransform` on `capabilities.gizmoTransform`; `isSplatModel` /
`isPlyModel` now read `adapterRef.current?.kind` directly.
- **`createLoad3d.ts`** (new): single factory that builds the renderer
(`createRenderer`), assembles all 13 managers in dependency order, and
threads one shared `AdapterRef` through `LoaderManager` and
`SceneModelManager`'s 4 capability lambdas.
- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: switch from `new
Load3d(container, options)` to `createLoad3d(container, options)`. No
other call-site changes.

## Review Focus

- **Capability dispatch parity**: walk each former hardcoded branch in
`SceneModelManager` and confirm it now falls out of the right
capability:
- `containsSplatMesh()` → `!capabilities.fitToViewer` +
`getDefaultCameraPose()`
- `handlePLYModeSwitch()` → `capabilities.requiresMaterialRebuild` +
`buildPointCloudForMaterialMode()`
- `Box3.setFromObject(model)` for sizing → `getBoundsFromAdapter(model)
?? Box3.setFromObject(model)`
- Mesh/Points geometry+material disposal in `clearModel` → still
happens, plus `disposeModelViaAdapter(obj)` for adapter-owned resources
(sparkjs SplatMesh internal GPU state)
- **`AdapterRef` lifecycle**: one ref is created in `createLoad3d`,
passed to `LoaderManager` (writer) and `SceneModelManager` (read via 4
closures). `LoaderManager.loadModel` clears via the *old* adapter first
(so `disposeModel` runs), then null-resets the ref before picking the
new one. Test `keeps the old adapter current while clearModel runs` pins
this ordering.
- **Splat fixes are user-visible, not pure refactor**:
- Orientation: `quaternion.set(1, 0, 0, 0)` matches the sparkjs README
convention. Without it splats render upside-down and mirrored on Z. Same
rotation is applied to the camera-from-matrices output in PR-E so a
future splat + camera-pose pair lines up.
- Decoder race: `await splatMesh.initialized` ensures `getBoundingBox`
returns a non-zero box on the first call. Without it `setupModel`'s
bounds → camera pipeline placed the OrbitControls target on the camera
origin, locking the view.
- Sizing: `fitTargetSize: 20` (vs. the mesh default of 5) means splat
geometry spans the full 20-unit grid footprint instead of ~1/4 of it.
Mesh assets are unaffected.
- **Gizmo gating**: `setGizmoEnabled(true)` early-returns when
`capabilities.gizmoTransform` is false. Internal
`setGizmoEnabled(false)` still runs (so we can always disable).
`setGizmoMode` / `resetGizmoTransform` / `applyGizmoTransform` no-op
when the capability is off.
- **`createLoad3d` is the single ctor entry**: `new Load3d(...)` is no
longer callable from app code (ctor signature changed to `(container,
deps, options)`). All call sites use `createLoad3d`. Test scaffolding
still uses `Object.create(Load3d.prototype)` + property injection where
it needs to bypass renderer creation.
- **Backwards compatibility**: `LoaderManager`'s `adapterRef` and
`SceneModelManager`'s 4 capability lambdas all have defaults
(`createAdapterRef()` and `() => DEFAULT_MODEL_CAPABILITIES` etc.), so
the existing test suites that construct these classes with the old
signatures still compile and pass without modification beyond what's in
this PR.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.7% | 86.7% | **100%** |
| `MeshModelAdapter.ts` (unchanged) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (modified) | **97.9%** | 69.2% | 71.4% |
**97.9%** |
| `SplatModelAdapter.ts` (modified) | **100%** | **100%** | **100%** |
**100%** |
| `SceneModelManager.ts` (modified) | 75.4% | 67.2% | 72.2% | 75.4% |
| `Load3d.ts` (modified) | 29.5% | 30.6% | 26.7% | 30.1% |
| `createLoad3d.ts` (new) | 83.8% | **100%** | 58.3% | 83.8% |
| `useLoad3d.ts` (modified) | 78.2% | 65.1% | 71.4% | 82.2% |
| `useLoad3dViewer.ts` (modified) | 75.2% | 52.1% | 65.9% | 79.4% |

`SplatModelAdapter.ts` jumps to 100% via 6 new tests covering the
orientation set, the `await initialized` decoder wait, `computeBounds`
(world-space transform + null fallback), `disposeModel` (per-SplatMesh
dispose + no-op on non-splat trees), and `defaultCameraPose`.

`createLoad3d.ts` hits 100% branch via a new test file with 12 cases —
`WebGLRenderer` config, `Load3DOptions` forwarding, `AdapterRef`
identity between `LoaderManager` and `SceneModelManager`, and the 4
capability lambdas in both adapter-null and adapter-published states
(each delegates correctly to the adapter's optional methods or falls
back to defaults). The remaining func% reflects the inline
`gizmoTransformChange` callback — not a deliberate skip, just out of
scope for the dispatch-wiring tests.

`SceneModelManager.ts` and `Load3d.ts` numbers are the pre-existing
baseline — the existing `*.test.ts` files cover façade methods via
prototype injection rather than instantiating the classes (`Load3d`
constructor needs `THREE.WebGLRenderer`, which happy-dom can't provide;
`SceneModelManager` covers the new capability paths via its existing
`createManager(overrides)` helper). All new branches (capability gating,
capability-driven `setupModel` / `fitToViewer` / rebuild, adapter-driven
`isSplatModel` / `isPlyModel`) have dedicated tests.

Net diff: **+846 / −370** across 16 files (10 production, 6 test).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11660-refactor-load3d-drive-viewer-behavior-from-ModelAdapter-capabilities-34f6d73d36508130b0ece884add182b9)
by [Unito](https://www.unito.io)
2026-04-27 21:32:21 -04:00
Dante
f404887c96 test: add unit tests for linkFixer (#11668)
## Summary

Adds unit coverage for `linkFixer` serialized graph repair paths and
fixes input-slot repairs being tracked without mutating the serialized
workflow data.

## Changes

- **What**: Adds 10 Vitest cases for valid links, dry-run reporting,
origin/target repair, missing input-slot cleanup, dangling node cleanup,
stale link deletion, and silent mode.
- **What**: Applies serialized input slot mutations in fix mode and
reports `fixed` only after a rerun confirms the graph is clean.
- **What**: Adds the design-system package to knip workspace config so
the pre-push hook recognizes its exported CSS dependency usage.
- **Dependencies**: None.

## Review Focus

The workflow validation path calls `fixBadLinks` with serialized
workflow JSON, so the new tests stay at that boundary and assert the
repaired graph shape directly.

## Test Coverage

- CI snapshot for `src/utils/linkFixer.ts`: unit lines 1.8%.
- Local targeted coverage: unit lines 83.9%, functions 94.11%, branches
75.15%.

## Testing

```bash
pnpm format -- src/utils/linkFixer.ts src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts
pnpm test:unit -- src/utils/linkFixer.test.ts --coverage
pnpm typecheck
pnpm lint
pnpm knip
```


## screenshoot
<img width="1691" height="927" alt="Screenshot 2026-04-28 at 10 09
26 AM"
src="https://github.com/user-attachments/assets/ff59888f-bde3-48f5-853a-60df69e84492"
/>
2026-04-28 01:11:25 +00:00
Comfy Org PR Bot
b37c66539b 1.44.12 (#11705)
Patch version increment to 1.44.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11705-1-44-12-3506d73d36508121bc39e748c87a6235)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-28 00:38:05 +00:00
Alexander Brown
95e9a9405b test(subgraph): pin #10849 promoted-widget-value corruption with it.fails (#11697)
*PR Created by the Glary-Bot Agent*

---

## Summary

#11579 restored *categorical* test coverage for subgraph serialization
but didn't reproduce the specific Z-Image-Turbo regression introduced by
#10849 — pre-#10849 templates whose `widgets_values` is leftover noise
get corrupted on load because the new code applies that array
positionally to promoted widget views.

This PR adds two **vitest** cases that pin the user-visible symptom
directly: after loading a misaligned legacy payload, the promoted widget
value should reflect the source default, not the legacy
`widgets_values[i]`.

Both use `it.fails` so the suite stays green while the bug is present
and flips to failing the moment the fix on
`fix/subgraph-promoted-widget-inline-state` lands.

## Tests

1. `falls back to source widget value when proxyWidgets is in legacy
2-tuple shape` — configure() with `proxyWidgets: [['-1', 'widget']]` +
`widgets_values: [999]` should leave the widget at the source default
(42), not 999.
2. `does not corrupt unbound promoted widgets when widgets_values length
mismatches view count` — same shape with longer/wrong-length array.

## Verification

- All 8 cases in the file pass under `it.fails` (CI green).
- Removing `.fails` locally produces the expected failures: `expected
42, received 999` and `expected 42, received 111` — confirming both
tests catch the regression.
- `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxlint` all clean.

## Why `it.fails` and not plain failing tests

The actual fix (`fix/subgraph-promoted-widget-inline-state`) is
unmerged. Landing genuinely-failing tests on main would break CI for
everyone. `it.fails` documents the bug runnably, keeps CI green, and
signals when the fix lands so the marker can be dropped.

## Why these assertions, not "widgets_values must be undefined"

A first draft asserted that `serialize()` should not write
`widgets_values` at all. That conflicts with existing coverage in the
same file (`preserves per-instance widget values after configure`,
`round-trips per-instance widget values`) which deliberately uses
`widgets_values` for round-trip persistence. These rewritten assertions
target the load-time corruption symptom directly without contradicting
the per-instance contract — feedback from Oracle review.

## Coordination

Mirrors the failing tests already on commit `6a982675e` of the unmerged
`fix/subgraph-promoted-widget-inline-state` branch, with the addition of
`.fails` markers and a clarifying comment so they can land on main
first.

Follow-up to #11579.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11697-test-subgraph-pin-10849-promoted-widget-value-corruption-with-it-fails-34f6d73d365081d7a04dcf48ebeceafe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-28 00:13:48 +00:00
Dante
00974d6339 test: add E2E tests for publish flow wizard (#10770)
## Summary
- Add Playwright E2E test coverage for the ComfyHub publish workflow
dialog
- Create `PublishDialog` page object fixture with programmatic dialog
opening via Vite dynamic imports
- Create `PublishApiHelper` for mocking all publish flow API endpoints
(`/hub/profiles/me`, `/hub/labels`, `/hub/workflows`,
`/userdata/*/publish`, `/assets/from-workflow`,
`/hub/assets/upload-url`)
- Add `data-testid` attributes to 6 publish flow components for stable
E2E locators
- 17 test scenarios across 7 describe blocks covering wizard navigation,
form interactions, profile gate, save prompt, and publish submission

## Test plan
- [ ] Run `pnpm test:browser:local -- --grep "Publish dialog"` against
local dev server
- [ ] Verify wizard navigation through Describe → Examples → Finish
steps
- [ ] Verify profile gate flow (with/without profile)
- [ ] Verify save prompt for unsaved workflows
- [ ] Verify publish success/failure scenarios

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10770-test-add-E2E-tests-for-publish-flow-wizard-3346d73d3650818094d5fc3a84593402)
by [Unito](https://www.unito.io)

---------

Co-authored-by: dante <dante@danteui-MacStudio.local>
Co-authored-by: GitHub Action <action@github.com>
2026-04-28 00:13:43 +00:00
Dante
878ffb70cc test: add unit tests for assetService (#11670)
## Summary

Extends `assetService.test.ts` (which previously only covered
`shouldUseAssetBrowser`) with behavioral tests for the network-bound
methods: metadata fetch error mapping, base64 upload validation,
async-vs-sync upload routing, delete error propagation, model-folder
filtering, update validation, and tag-filtered list defaults.

## Changes

- **What**: Adds 10 Vitest cases across `getAssetMetadata`,
`uploadAssetFromBase64`, `uploadAssetAsync`, `deleteAsset`,
`getAssetModelFolders`, `updateAsset`, and `getAssetsByTag`. Reuses the
existing hoisted `mockDistributionState` / `mockSettingStoreGet` setup
and the existing `vi.mock('@/scripts/api')` boundary; adds local
`buildResponse` and `validAsset` helpers scoped to this file.

## Review Focus

- The localized error path is covered through public methods
(`getAssetMetadata`) rather than reaching for the internal
`getLocalizedErrorMessage`.
- `getAssetModelFolders` test asserts that the request URL omits
`include_public` (the internal call site passes no `includePublic`),
matching the conditional in `handleAssetRequest`.
- `uploadAssetAsync` tests pin the discriminated-union shape (`type:
'async' | 'sync'`) for both 202 and 200 responses.

## Testing

\`\`\`bash
pnpm exec vitest run src/platform/assets/services/assetService.test.ts
pnpm format -- src/platform/assets/services/assetService.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

All 16 tests pass (6 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11670-test-add-unit-tests-for-assetService-34f6d73d36508117b1aafaf463e9c820)
by [Unito](https://www.unito.io)
2026-04-28 08:45:01 +09:00
Alexander Brown
a441364a55 refactor(litegraph): centralize _version counter via incrementVersion() (#11698)
## Summary

Centralize all `LGraph._version` increments behind a single
`incrementVersion()` method to create the seam for a future
`VersionSystem` (ECS Migration Phase 0a).

## Changes

- **What**: Added `LGraph.incrementVersion()` and replaced all 19 direct
`graph._version++` writes across 7 files. Existing null guards at call
sites are preserved. Zero behavioral change — the counter is still only
read by `LGraphCanvas.renderInfo()` for debug display.

## Review Focus

- The new method is mechanical: `incrementVersion(): void {
this._version++ }`. Look for any sites I missed or null-guard
regressions.
- Files updated: `LGraph.ts`, `LGraphNode.ts`, `LGraphCanvas.ts`,
`widgets/BaseWidget.ts`, `subgraph/SubgraphInput.ts`,
`subgraph/SubgraphInputNode.ts`, `subgraph/SubgraphOutput.ts`.

Part of the [ECS Migration
Plan](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md).
Linear: FE-165.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11698-refactor-litegraph-centralize-_version-counter-via-incrementVersion-34f6d73d3650810992f8fa3adbae3f38)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 23:25:41 +00:00
Alexander Brown
8dcadd6fe1 test: restore deleted subgraph serialization E2E tests (#11579)
*PR Created by the Glary-Bot Agent*

---

## Summary

#10759 removed ~12 E2E tests from `subgraphSerialization.spec.ts` during
a reorganization that shifted semantic coverage to Vitest. Several of
the removed tests covered the exact `serialize() → JSON → configure()`
round-trip that #10849's positional `_instanceWidgetValues` path later
regressed on Main — promoted widget values binding to the wrong slots
when loading templates whose `widgets_values` ordering doesn't match
current `proxyWidgets`.

This restores the pre-reorg E2E coverage so future regressions in
promoted-widget serialization are caught at the browser level.

## Restored tests

From `subgraphSerialization.spec.ts` (pre-#10759):

- **Deterministic proxyWidgets Hydrate** (3 tests) — round-trip
stability and compressed `target_slot` resolution.
- **Legacy And Round-Trip Coverage** (5 tests) — includes the most
directly-relevant restorations:
  - `Promoted widgets survive serialize -> loadGraphData round-trip`
  - `Multi-link input representative stays stable through save/reload`
- `Cloning a subgraph node keeps promoted widget entries on original and
clone`
- **Duplicate ID Remapping** (5 tests) — includes `Promoted widget
tuples are stable after full page reload boot path`.

The 4 tests that already existed on Main (added by #10849 and the
Vue-nodes legacy-prefixed block) are kept as-is.

## Adaptations to current APIs

- Imports reworked for the post-`@e2e/*` alias layout.
- Redundant `comfyPage.nextFrame()` calls dropped — `loadWorkflow` /
`loadGraphData` / `serializeAndReload` already wait internally (#11264).
- Alt-drag clone block wrapped in `try/finally` around
`keyboard.up('Alt')` to match the current `subgraphCrud.spec.ts`
pattern.
- `PromotedWidgetEntry` is now exported from
`browser_tests/helpers/promotedWidgets.ts` so the restored
`expectPromotedWidgetsToResolveToInteriorNodes` helper can type its
argument.

## Review follow-ups applied

- Use `expect.poll()` instead of `expect(async () => …).toPass()` for
the single-value snapshot comparison, per `browser_tests/AGENTS.md`.
- Capture and call `dispose()` from
`SubgraphHelper.collectConsoleWarnings()` inside a `try/finally` so the
console listener is unregistered after the test.

## Verification

- `pnpm typecheck:browser` — clean.
- `pnpm exec eslint` + `pnpm exec oxlint` on changed files — 0 warnings,
0 errors.
- `pnpm exec oxfmt` on changed files — applied (no diff).
- Ran 2 key restored tests against local ComfyUI + dev server:
- `Promoted widgets survive serialize -> loadGraphData round-trip` —
PASS (3.9s)
- `Multi-link input representative stays stable through save/reload` —
PASS (2.9s)
- Full-suite runs in this sandbox are blocked by the existing
`comfyPage` fixture's `createUser` path failing on repeat runs against
persistent backend state — unrelated to this PR. CI will exercise the
full suite.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11579-test-restore-deleted-subgraph-serialization-E2E-tests-34b6d73d365081f29b27c1069476ad17)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 21:31:59 +00:00
Simon Pinfold
3a05a37323 Fix naming strategy for multi-job asset exports (#11610)
## Summary

Use `group_by_job_time` for exports spanning multiple jobs while keeping
single-job exports on `preserve`, and add regression coverage for the
new naming-strategy behavior.

## Changes

- **What**: updated the asset export payload and request typing for the
new naming-strategy values, added unit coverage for single-job vs
multi-job export requests, added `@cloud` sidebar browser coverage for
export payloads, and adjusted the cloud Playwright setup helpers so
setup API calls can hit the backend directly and Firebase auth is seeded
on the app origin
- **Breaking**: none
- **Dependencies**: none

## Review Focus

Please sanity-check the cloud Playwright harness changes in `ComfyPage`
and `CloudAuthHelper`, plus the single-job vs multi-job export
naming-strategy assertions in the new browser tests.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11610-Fix-naming-strategy-for-multi-job-asset-exports-34c6d73d365081a68a88ea38d897578f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-27 20:44:32 +00:00
Dante
6cfa5fdb1f test: add unit tests for assetsStore (#11672)
## Summary

Extends `assetsStore.test.ts` with behavioral coverage for the
optimistic-update flows (`updateAssetMetadata`, `updateAssetTags`), the
per-asset deletion-state tracker (`setAssetDeleting` /
`isAssetDeleting`), the input-name resolution (`getInputName` /
`inputAssetsByFilename`), and the cloud-routing branch of
`updateInputs`.

## Changes

- **What**: Adds 8 Vitest cases across 4 new `describe` blocks
(`updateAssetMetadata optimistic cache`, `updateAssetTags diff-based
dispatch`, `setAssetDeleting / isAssetDeleting`, `getInputName`,
`updateInputs cloud routing`). Extends the shared `assetService` mock
with `updateAsset`, `addAssetTags`, and `removeAssetTags` (none of which
were previously stubbed).

## Review Focus

- Cloud-routed tests flip `mockIsCloud.value` inside a `try/finally` so
the existing default (`false`) is restored even if an assertion throws —
same pattern the existing Cloud describe block uses.
- The optimistic-cache rollback test silences the `console.error`
invoked by the catch branch so the test output stays clean.
- The `updateAssetTags` tests pin both the no-op short-circuit and the
add-only path. Remove-only and combined add+remove are already exercised
indirectly through the existing tag-cache invalidation tests.

## Testing

\`\`\`bash
pnpm exec vitest run src/stores/assetsStore.test.ts
pnpm format -- src/stores/assetsStore.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

47 tests pass (39 prior + 8 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11672-test-add-unit-tests-for-assetsStore-34f6d73d3650819f8e43e7154541baef)
by [Unito](https://www.unito.io)
2026-04-27 20:40:27 +00:00
Benjamin Lu
3ea75e1c48 fix: localize secret date labels (#11524)
## Summary

Localizes secret list date labels through Vue I18n date formatting
instead of the browser default numeric date format, with Storybook
coverage for design review.

## Changes

- **What**: Replaces `toLocaleDateString()` with `useI18n().d(..., {
dateStyle: 'medium' })` for `SecretListItem` dates.
- **What**: Updates `SecretListItem` expectations to match the Vue I18n
date formatter.
- **What**: Adds `SecretListItem` stories for default, never-used,
loading, and disabled states.
- **Dependencies**: None.

## Review Focus

Stacked on #11480 to keep the escaping fix scoped. Please confirm
whether the localized medium date style matches design/product
expectations.

## Screenshots (if applicable)


https://f3ba7229.comfy-storybook.pages.dev/?path=/story/platform-secrets-secretlistitem--default

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11524-fix-localize-secret-date-labels-3496d73d3650814bb70bfb3f870a31cd)
by [Unito](https://www.unito.io)
2026-04-27 13:11:31 -07:00
Alexander Brown
9c0edd0048 fix: prevent duplicate website-e2e CI runs on PRs (#11607)
## Summary

Fix duplicate `CI: Website E2E` workflow runs on pull requests.

## Problem

Two runs were triggered for every PR touching website files:
- `website-e2e (pull_request)` — from the PR event
- `website-e2e (push)` — from the push to a `website/*` branch

The concurrency key used `github.ref`, which evaluates differently for
push (`refs/heads/...`) vs pull_request (`refs/pull/N/merge`), so they
couldn't cancel each other.

## Changes

1. Scope `push` trigger to `main` only (removes `website/*`)
2. Use `github.head_ref || github.ref` in the concurrency group so push
and PR events for the same branch share a group

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11607-fix-prevent-duplicate-website-e2e-CI-runs-on-PRs-34c6d73d3650814c9d24c77b1591e94a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 19:55:05 +00:00
Benjamin Lu
7ee667c1d1 fix: avoid escaped secret date labels (#11480)
Global i18n config has escapeParameter as true. This explicitly turns it
to false. I opened a Linear ticket to reconsider changing this back to
false as default globally.

## Summary

Fix the Secrets panel so created and last-used dates render as plain
text instead of HTML-escaped slash entities.

## Changes

- **What**: Compute the Secrets date labels with `t(..., {
escapeParameter: false })` after formatting the date, so vue-i18n does
not escape `/` into `&#x2F;` for plain-text output.
- **What**: Replace the mocked translation setup in
`SecretListItem.test.ts` with a real `vue-i18n` instance and add a
regression test that asserts the rendered dates do not contain escaped
slash entities.

## Review Focus

This intentionally fixes the i18n interpolation issue shown in the bug
screenshot. It does not change the separate RFC3339Nano parsing behavior
discussed in #11358.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11480-fix-avoid-escaped-secret-date-labels-3486d73d365081c890ecd2a6992d7879)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:46:01 -07:00
Benjamin Lu
2b010ac8b3 fix: dedupe pending checkout attempt construction (#11622)
## Summary

Deduplicates pending subscription checkout attempt construction so
storage and fallback paths share the same payload creation.

## Changes

- **What**: Build the `PendingSubscriptionCheckoutAttempt` once in
`recordPendingSubscriptionCheckoutAttempt()` and reuse it for
unavailable-storage, failed-write, and successful-write paths.
- **Dependencies**: None.

## Review Focus

This is intended as a no-behavior-change cleanup: unavailable storage
still returns an attempt, failed `setItem()` still returns that attempt
without dispatching, and the pending-checkout event only fires after a
successful storage write.

Linear: FE-209

## Screenshots (if applicable)

N/A

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 12:28:18 -07:00
jaeone94
b4d209b5f6 feat: refresh missing models through pipeline (#11661)
## Summary

Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.

Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.

## Changes

- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.

The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.

Expected behavior by environment:

- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
  - Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
  - Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.

A few boundaries are intentional:

- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.

Linear: FE-417

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772


## Validation

- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
  - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
2026-04-27 18:53:50 +00:00
Christian Byrne
9a70676c61 [chore] Update Comfy Registry API types from comfy-api@c82adf6 (#11689)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: c82adf6
- Generated on: 2026-04-25T22:16:06Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11689-chore-Update-Comfy-Registry-API-types-from-comfy-api-c82adf6-34f6d73d3650815881e4dae4bdf05696)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
2026-04-27 18:41:46 +00:00
Christian Byrne
9ce4c18eb1 test: add unit tests for useGLSLRenderer (#11390)
## Summary

Adds 44 unit tests for the `useGLSLRenderer` composable
(`src/renderer/glsl/useGLSLRenderer.ts`), increasing coverage from ~15%
to near-complete line coverage.

## Test Coverage

| Describe Block | Tests | What's Covered |
|---|---|---|
| `init` | 6 | Context creation, FBO setup, `UNPACK_FLIP_Y_WEBGL`, FBO
failure cleanup, post-dispose guard |
| `compileFragment` | 9 | Shader compile success/failure, program link
errors, program re-creation, dispose guard |
| `setResolution` | 4 | Viewport + FBO recreation on resize, no-op on
same size, dispose guard |
| `setFloatUniform` | 2 | Float uniform dispatch, dispose guard |
| `setIntUniform` | 2 | Int uniform dispatch, dispose guard |
| `bindInputImage` | 4 | Texture creation/binding, out-of-range index,
re-bind cleanup, dispose guard |
| `render` | 7 | Draw call, `u_resolution`, fallback textures, final
pass to default FBO, multi-pass ping-pong, dispose/no-program guards |
| `readPixels` | 2 | Returns `ImageData`, throws when uninitialized |
| `toBlob` | 2 | Returns `Blob`, throws when uninitialized |
| `dispose` | 4 | GL resource cleanup, idempotency, `onScopeDispose`,
input texture cleanup |
| `config` | 2 | Default vs custom uniform/input counts |

## Approach

- Mocks `WebGL2RenderingContext`, `OffscreenCanvas`, and `ImageData`
(happy-dom has no WebGL)
- Uses Vue `effectScope` to test `onScopeDispose` cleanup
- Mocks `detectPassCount` from `glslUtils` for multi-pass tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11390-test-add-unit-tests-for-useGLSLRenderer-3476d73d3650815a8787cd9368fd8baf)
by [Unito](https://www.unito.io)
2026-04-27 10:45:37 -04:00
Christian Byrne
56aec1878a test: add unit tests for GPUBrushRenderer (#11388)
## Summary

Add unit tests for `GPUBrushRenderer`, increasing coverage from ~3.5% to
cover constructor initialization, stroke rendering, compositing, preview
blitting, readback, and resource cleanup.

## Changes

- **What**: 27 unit tests for `GPUBrushRenderer` covering all public
methods with comprehensive WebGPU API mocks

## Review Focus

Mock factory approach for WebGPU objects — all GPU globals
(`GPUBufferUsage`, `GPUTextureUsage`, `GPUShaderStage`) are polyfilled
since happy-dom lacks WebGPU support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11388-test-add-unit-tests-for-GPUBrushRenderer-3476d73d3650814ab0e2c0fb8c424faa)
by [Unito](https://www.unito.io)
2026-04-27 10:34:33 -04:00
Dante
502a02213a test: add unit tests for useWorkspaceBilling polling and refresh paths (#11676)
## Summary

Extends `useWorkspaceBilling.test.ts` with five behavioral gaps in the
existing suite: `initialize` does not double-fetch balance when free
tier already has positive balance, `subscribe(planSlug)` forwards
`undefined` for return/cancel URLs, `previewSubscribe` does not refresh
status or balance after success, `pollCancelStatus` falls back to a
default error message when failed status omits `error_message`, and
`pollCancelStatus` halts further scheduled polls when a later poll's API
call rejects.

## Changes

- **What**: Adds 5 Vitest cases across four new `describe` blocks
(`initialize free-tier balance refresh`, `subscribe argument
forwarding`, `previewSubscribe does not refresh state`,
`pollCancelStatus error paths`). Reuses the existing `setupBilling()`
factory and `effectScope` lifecycle.

## Review Focus

- The mid-poll rejection test silences `unhandledRejection` for its
duration: `pollCancelStatus`'s rescheduled poll runs through `void
poll()` inside `setTimeout`, so the catch block's rethrow has no awaiter
and surfaces as unhandled. The test pins the observable behavior (no
further scheduled polls) without claiming `cancelSubscription`
propagates the late error.
- The `subscribe(planSlug)` test pins the call shape with explicit
`undefined` arguments so a future signature change breaks the test
rather than silently passing through.
- The free-tier branch is targeted: previously only the zero-balance
reload was tested; this adds the negative case (positive balance → no
second fetch).

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm format --
src/platform/workspace/composables/useWorkspaceBilling.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

38 tests pass (33 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11676-test-add-unit-tests-for-useWorkspaceBilling-polling-and-refresh-paths-34f6d73d36508197bc7bc66d54e805e0)
by [Unito](https://www.unito.io)
2026-04-27 10:25:54 -04:00
Dante
f1ea3b02a6 test: add unit tests for useNodeReplacement transfer edge cases (#11677)
## Summary

Extends `useNodeReplacement.test.ts` with five connection-transfer and
graph-mutation edge cases that the existing 23-case suite did not cover:
missing-old-input-slot skip, missing-new-output-index resilience,
set_value on a non-existent widget, set_value with dot-notation new_id,
and the Vue-node refresh path via `nodeGraph.onNodeAdded`.

## Changes

- **What**: Adds 5 Vitest cases in a new `transfer edge cases` describe
block. Reuses the existing `createPlaceholderNode`, `createNewNode`,
`createMockGraph`, `createMockLink`, and `makeMissingNodeType` helpers —
no new test infrastructure introduced.

## Review Focus

- The "missing new output index" test verifies that `replaceWithMapping`
does not throw when `newNode.outputs[newOutputIdx]` is absent, and
asserts the original link's `origin_id` is unchanged so the silent-skip
behavior is pinned (not a swallowed exception).
- The dot-notation `set_value` test pins that the existing dot-notation
guard at `useNodeReplacement.ts:203` covers the `set_value` branch (not
just the `old_id` connection branch already covered at line 187).
- The `onNodeAdded` test asserts the Vue-node sync path that runs after
`replaceWithMapping` bypasses `graph.add()` — a future refactor that
drops the explicit call would silently break the Vue node renderer
otherwise.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm format -- src/platform/nodeReplacement/useNodeReplacement.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

28 tests pass (23 prior + 5 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11677-test-add-unit-tests-for-useNodeReplacement-transfer-edge-cases-34f6d73d3650817aa2ffccdb9fb4a947)
by [Unito](https://www.unito.io)
2026-04-27 10:25:26 -04:00
Christian Byrne
b2bba78ce0 test: add unit tests for usePanAndZoom composable (#11391)
## Summary

Add 29 unit tests for the `usePanAndZoom` composable to improve mask
editor test coverage.

## Changes

- **What**: New test file
`src/composables/maskeditor/usePanAndZoom.test.ts` covering all public
API methods

## Review Focus

Test coverage spans:
- `initializeCanvasPanZoom` — landscape/portrait fit-to-view, panel
width accounting, style application
- `handlePanStart`/`handlePanMove` — panning state, offset updates,
error on missing start
- `zoom` — wheel in/out, clamping (0.2–10.0), missing canvas early
return, cursor update
- `updateCursorPosition` — pan offset application
- `invalidatePanZoom` — missing image warning, store container fallback,
rgbCanvas dimension sync
- Touch handlers — single/two-finger start, double-tap undo, pen
blocking, single-touch pan, pinch zoom, touch end states
- `addPenPointerId`/`removePenPointerId` — add, dedup, remove, no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11391-test-add-unit-tests-for-usePanAndZoom-composable-3476d73d36508104b447d87471ce021b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-27 10:24:44 -04:00
Dante
3d14bfb09c fix: render asset fixtures in AssetBrowserModal stories (#11502)
## Why this fix exists

Surfaced as a dead-end during the FE-227 (asset modal scroll breakage in
cloud-prod) root-cause investigation, **not** as a standalone Storybook
complaint.

The natural local repro path for FE-227 is "bump an `AssetBrowserModal`
Storybook story to ~120 assets and watch the layout misbehave." When
that path was attempted, the stories rendered empty modals regardless of
fixture size. A Codex adversarial review confirmed the cause: three
stories bind `:assets="..."` to a prop the component never declared, so
the binding is silently dropped and the modal falls back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook.

The empty-modal failure mode also silently broke design QA / visual
review on this surface: any reviewer opening these stories has been
seeing "No assets found" for as long as the bug has existed.

Filing this as its own PR so:
- FE-227 stays focused on the cloud-prod scroll bug once a DevTools
datapoint confirms the hypothesis.
- The local repro path for FE-227 (and any future asset-modal layout
regression) becomes usable.
- Visual review on `AssetBrowserModal` is restored.

## What changed

Three `AssetBrowserModal` stories bound `:assets="..."` to a
non-existent prop, so the modal silently fell back to
`assetsStore.getAssets(cacheKey)` — which returns an empty array in
Storybook because the model cache only initializes in cloud distribution
builds. Add an optional `assets` prop on `AssetBrowserModal` that, when
provided, bypasses the store fetch. Production callers continue to use
the store; this is a narrowly scoped Storybook/test seam.

- Fixes FE-232

### Why a prop on the component (Option 1) and not a Storybook decorator
(Option 2)

`assetsStore`'s model cache (`getModelState`) is gated by `if
(isCloud)`, returning an empty stub for desktop/localhost distributions.
Storybook's `.storybook/main.ts` does not define `__DISTRIBUTION__`, so
`isCloud === false` and the store has no public API to seed assets.
Public seed methods (`updateModelsForNodeType` / `updateModelsForTag`)
only delegate to `assetService` network calls. Option 2 (decorator-based
seeding) would require either patching Storybook's Vite `define` config
or building a parallel mock store via `resolve.alias` (the `useJobList`
precedent) — significantly more invasive than a +10 / -1 line component
change. The new prop is documented as a Storybook/test seam in JSDoc and
changes nothing for production callers.

### Bonus

`useAssetBrowserDialog.stories.ts:120` had the **same** broken
`:assets="mockAssets"` binding. The new prop transparently repairs it
without a separate change.

## Before

All three stories render an empty modal (`No assets found`) regardless
of the fixture data they pass.

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `before-default.png` → Default story
> - `before-single-asset-type.png` → Single asset type story
> - `before-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
| Default | |
<img width="961" height="821" alt="before-default"
src="https://github.com/user-attachments/assets/4a0af0f5-b712-41e2-adbc-c2b4b921045d"
/>

| Single asset type | |
<img width="961" height="821" alt="before-single-asset-type"
src="https://github.com/user-attachments/assets/073a8fa8-7bbb-4ec9-a226-156b7141d9b5"
/>

| No left panel | <img width="961" height="821"
alt="before-no-left-panel"
src="https://github.com/user-attachments/assets/0d45ff3b-5866-4de9-b7aa-5bd9cb1f3566"
/> |

## After

All three stories now render their intended fixture data (asset cards
visible with mock model names, badges, sort/filter controls populated).

> Drag-drop the screenshots into the slots below from
`/tmp/fe-232-screenshots/`:
> - `after-default.png` → Default story
> - `after-single-asset-type.png` → Single asset type story
> - `after-no-left-panel.png` → No left panel story

| Story | Screenshot |
|---|---|
<img width="961" height="821" alt="after-default"
src="https://github.com/user-attachments/assets/a11b2475-bd18-4c30-aece-cf1bdbcc6ac5"
<img width="961" height="821" alt="after-single-asset-type"
src="https://github.com/user-attachments/assets/71e11237-006b-43d9-90de-e9d2d8894e34"
/>
 />

| Default |  |
| Single asset type |   |
| No left panel | <img width="961" height="821"
alt="after-no-left-panel"
src="https://github.com/user-attachments/assets/5123db87-2ab9-4359-8e61-ac0d8da9494c"
/>
  |


## Test plan

- [x] Storybook stories now render fixture data (manually verified all
three via Chrome DevTools MCP)
- [x] `pnpm typecheck` passes on touched files
- [x] `pnpm lint` passes on touched files
- [x] Existing `AssetBrowserModal.test.ts` (14 tests) still passes
- [x] `useAssetBrowserDialog.stories.ts` is also functional (same bug
pattern, repaired by the new prop)
- [ ] No new prop surface added to `AssetBrowserModal` other than the
documented Storybook/test seam (`assets?: AssetItem[]`)
2026-04-27 11:30:59 +00:00
Dante
3340b77908 test: add unit tests for value-control widget family (#11440)
## Summary

Adds 42 unit tests across 5 files covering the value-control widget
family — first batch of a broader effort to raise widget-test coverage.

## Changes

- **What**:
- `WidgetInputNumber.test.ts` (9) — variant selection by widget.type
(int/float/slider/gradientslider), controlWidget wrapping in
WidgetWithControl, modelValue forwarding.
- `WidgetInputNumberGradientSlider.test.ts` (11) — initial value,
min/max/disabled pass-through, default vs custom gradient stops,
precision-derived step, WidgetLayoutField wrapping.
- `WidgetWithControl.test.ts` (5) — renders passed component with
widget/modelValue, initializes ValueControlButton mode from
widget.controlWidget.value, calls controlWidget.update on mode change.
- `ValueControlButton.test.ts` (11) — i18n aria-label per mode, text vs
icon rendering, pointer and keyboard activation, `type="button"` safety.
- `ValueControlPopover.test.ts` (6) — BEFORE/AFTER copy from
settingStore, four option render, v-model updates on selection.

## Review Focus

- Stack follows the existing widget-test pattern (`@testing-library/vue`
+ PrimeVue + `createI18n` where needed, no `@vue/test-utils`).
- `createMockWidget` from `widgetTestUtils.ts` reused; no new helper
extracted (YAGNI — per-file `renderComponent` stays ~10 lines).
- `WidgetWithControl` watcher test asserts first-arg of `update` since
the vue watch callback passes `(newVal, oldVal, onCleanup)`.
- No changes to any widget component source — tests-only PR.

This is one of several focused PRs in a widget-test-coverage sequence;
subsequent PRs cover form-dropdown internals, utility widgets,
media/graph/canvas widgets, and e2e value-type specs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11440-test-add-unit-tests-for-value-control-widget-family-3486d73d3650813891e1fe8d45eaecaf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-27 20:32:31 +09:00
Dante
0ad85087ea test: add unit tests for useMissingModelInteractions (#11675)
## Summary

Extends `useMissingModelInteractions.test.ts` with behavioral coverage
for the previously untested public surface: `getComboOptions` (both
asset-supported and widget-driven branches), `getDownloadStatus`, and
the four `handleImport` outcomes (async-pending, async-completed,
sync-with-mismatch, error).

## Changes

- **What**: Adds 10 Vitest cases across three new `describe` blocks
(`getComboOptions`, `getDownloadStatus`, `handleImport`). Extends the
existing module-level mocks with `mockUploadAssetAsync`,
`mockTrackDownload`, and `mockInvalidateModelsForCategory` so the import
flow can be verified at its boundaries.

## Review Focus

- The `handleImport` block uses a shared `setupImportableState(key)`
helper to seed `urlInputs` + `urlMetadata` and stub `validateSourceUrl`
once per test. Each case then asserts a single boundary effect (taskId
tracked, cache invalidated, mismatch recorded, error stored).
- The `getDownloadStatus` happy path relies on the existing getter-style
`mockDownloadList` so the test's mutation lands in the composable
without re-stubbing the asset download store.
- The `getComboOptions` "asset-supported" test asserts both the call
shape (`mockGetAssets` invoked with the candidate's `nodeType`) and the
output shape, so a future refactor that swaps the lookup key fails
loudly.

## Testing

\`\`\`bash
pnpm exec vitest run
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm format --
src/platform/missingModel/composables/useMissingModelInteractions.test.ts
pnpm lint
pnpm typecheck
pnpm knip
\`\`\`

44 tests pass (34 prior + 10 new).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11675-test-add-unit-tests-for-useMissingModelInteractions-34f6d73d36508112909fe8e49cc68010)
by [Unito](https://www.unito.io)
2026-04-27 06:21:49 -04:00
pythongosssss
4c892341e4 feat: Node search UX updates (#9714)
## Summary

Addresses feedback from the initial v2 node search implementation for
improved UI and UX

## Changes

- **What**: 
- add root filter buttons
- remove all extra tree categories leaving only "Most relevant"
- replace input/output selection with popover
- replace price badge with one from node header
- add chevrons and additional styling to category tree
- hide empty categories
- fix bug with hovering selecting item under mouse automatically
- fix tailwind merge with custom sizes removing them
- keyboard navigation
- general tidy/refactor/test

## Screenshots (if applicable)

https://github.com/user-attachments/assets/db798dfa-e248-4b48-bb56-2fa7b6c5f65f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9714-feat-Node-search-UX-updates-31f6d73d365081cebd96c4253ad1ca53)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:47:47 +00:00
Christian Byrne
eb8f8b75b5 fix: correct zh-CN translations across landing, product, and pricing pages (#11655)
*PR Created by the Glary-Bot Agent*

---

## Summary

Applies translation corrections to the Chinese (zh-CN) version of the
website, requested in the `#project-new-website-brand-refresh` Slack
thread by a native-speaker reviewer.

### Changes (in `apps/website/src/i18n/translations.ts` +
`apps/website/src/pages/zh-CN/index.astro`)

- **Landing — blog section badges**: `如何` → `了解`, `运作` → `运行方式`
- **Landing — hero headline**: `专业控制` → `最强可控性` (also updates the zh-CN
page `<title>`)
- **Landing — hero subtitle**: `Comfy 是面向视觉专业人士的 AI
创作引擎,让您掌控每个模型、每个参数和每个输出。` → `Comfy 是面向专业视觉人士的 AI
创作引擎。您可以精确掌控每个模型、每个参数和每个输出。`
- **Landing — showcase subtitle**: `从社区模板开始,或从零构建。` → `从工作流模板开始,或从零构建。`
- **Landing — showcase feature1 title**: `节点式完全控制` → `节点带来的可控性`
- **Landing — use case body**: `由 60,000+ 节点、数千个工作流 和一个比任何公司都更快构建的社区驱动。`
→ `60,000+ 节点,数千条工作流,一个比任何公司速度都更快的社区。`
- **All `查看xx特性` CTAs** (local / cloud / API / enterprise product
cards): `特性` → `属性`
- **All `合作节点` pricing copy**: `合作节点` → `合作伙伴节点` (correct translation
for "partner node")

### Verification

- `pnpm typecheck` (astro check): 0 errors, 0 warnings, 0 hints
- `pnpm build`: completes successfully; rebuilt zh-CN pages contain the
new strings and no longer contain the old ones
- Manual Playwright verification on `/zh-CN/` (hero, showcase badges,
showcase feature card, use-case body, product cards) and
`/zh-CN/cloud/pricing/` (`合作伙伴节点` appears 4×, `合作节点` appears 0×)

### Notes on review feedback

A review pass flagged two of the swaps (`特性→属性` and `从社区模板→从工作流模板`) as
potential style concerns. Both changes are kept as-is because they were
specified verbatim by the native-speaker reviewer who requested this
pass.

## Screenshots

![zh-CN landing hero showing new headline and rewritten
subtitle](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526109-4a011f24-da46-4b6b-ba01-bce9cc8aec63.png)

![zh-CN showcase section showing updated badges, workflow template
subtitle, and node controllability feature
card](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526507-3054b3d7-0950-4c09-8edc-643c913ca1dc.png)

![zh-CN use-case section showing reworded 60,000+ node
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246526822-772cd907-0a70-441b-ac0c-bef2d1eb0f9f.png)

![zh-CN product cards showing updated
CTAs](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6fc28cd940457f72106b8b42ad6994e300faa83f49cf837ef8dcbfdd7bf89c6b/pr-images/1777246527170-d1969308-5e6c-4d8f-bfee-24e31b0d0670.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11655-fix-correct-zh-CN-translations-across-landing-product-and-pricing-pages-34e6d73d3650816daddde4a90fb47a01)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-27 06:18:47 +00:00
dante01yoon
87bcff999c refactor: extract widget classification from promotionUtils
Move widget classification logic (isPreviewPseudoWidget, isRecommendedWidget,
getPromotableWidgets) into dedicated widgetClassification module. Add missing
isRecommendedWidget tests. No behavior change.
2026-04-22 06:38:53 +09:00
186 changed files with 13576 additions and 2351 deletions

View File

@@ -2,7 +2,7 @@ name: 'CI: Website E2E'
on:
push:
branches: [main, website/*]
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
@@ -17,7 +17,7 @@ on:
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:

View File

@@ -4,12 +4,12 @@ const translations = {
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
'zh-CN': '视觉 AI 的\n专业控制'
'zh-CN': '视觉 AI 的\n最强可控性'
},
'hero.subtitle': {
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
'zh-CN':
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
},
// ProductShowcaseSection
@@ -20,11 +20,11 @@ const translations = {
},
'showcase.subtitle2': {
en: 'Start from a community template or build from scratch.',
'zh-CN': '从社区模板开始,或从零构建。'
'zh-CN': '从工作流模板开始,或从零构建。'
},
'showcase.feature1.title': {
en: 'Full Control with Nodes',
'zh-CN': '节点式完全控制'
'zh-CN': '节点带来的可控性'
},
'showcase.feature1.description': {
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
@@ -49,8 +49,8 @@ const translations = {
'zh-CN':
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
},
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运' },
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
// UseCaseSection
'useCase.label': {
@@ -83,8 +83,7 @@ const translations = {
},
'useCase.body': {
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
'zh-CN':
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
},
'useCase.cta': {
en: 'EXPLORE WORKFLOWS',
@@ -164,7 +163,7 @@ const translations = {
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版性'
'zh-CN': '查看本地版性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -176,7 +175,7 @@ const translations = {
},
'products.cloud.cta': {
en: 'SEE CLOUD FEATURES',
'zh-CN': '查看云端性'
'zh-CN': '查看云端性'
},
'products.api.title': {
en: 'Comfy\nAPI',
@@ -188,7 +187,7 @@ const translations = {
},
'products.api.cta': {
en: 'SEE API FEATURES',
'zh-CN': '查看 API 性'
'zh-CN': '查看 API 性'
},
'products.enterprise.title': {
en: 'Comfy\nEnterprise',
@@ -200,7 +199,7 @@ const translations = {
},
'products.enterprise.cta': {
en: 'SEE ENTERPRISE FEATURES',
'zh-CN': '查看企业版性'
'zh-CN': '查看企业版性'
},
// CaseStudySpotlightSection
@@ -1215,7 +1214,7 @@ const translations = {
'pricing.included.feature4.description': {
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
'zh-CN':
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro。'
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro。'
},
'pricing.included.feature5.title': {
en: 'Add more credits anytime',
@@ -1245,12 +1244,12 @@ const translations = {
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作节点'
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',

View File

@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
---
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection />
<ProductShowcaseSection locale="zh-CN" client:load />

View File

@@ -51,6 +51,7 @@ DISABLE_VUE_PLUGINS=true
# Test against dev server (recommended) or backend directly
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above
# Path to ComfyUI for backing up user data/settings before tests
TEST_COMFYUI_DIR=/path/to/your/ComfyUI

View File

@@ -0,0 +1,74 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 50],
"size": [400, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 1,
"type": "Painter",
"pos": [450, 50],
"size": [450, 550],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "Painter"
},
"widgets_values": ["", 512, 512, "#000000"]
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -137,6 +137,7 @@ class ComfyMenu {
export class ComfyPage {
public readonly url: string
public readonly apiUrl: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly selectionToolbox: Locator
@@ -195,6 +196,7 @@ export class ComfyPage {
public readonly request: APIRequestContext
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url
this.canvas = page.locator('#graph-canvas')
this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root)
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
@@ -236,7 +238,7 @@ export class ComfyPage {
}
async setupUser(username: string) {
const res = await this.request.get(`${this.url}/api/users`)
const res = await this.request.get(`${this.apiUrl}/api/users`)
if (res.status() !== 200)
throw new Error(`Failed to retrieve users: ${await res.text()}`)
@@ -250,7 +252,7 @@ export class ComfyPage {
}
async createUser(username: string) {
const resp = await this.request.post(`${this.url}/api/users`, {
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
data: { username }
})
@@ -262,7 +264,7 @@ export class ComfyPage {
async setupSettings(settings: Record<string, unknown>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
`${this.apiUrl}/api/devtools/set_settings`,
{
data: settings
}

View File

@@ -5,12 +5,14 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator
readonly filterSearch: Locator
readonly results: Locator
readonly filterOptions: Locator
constructor(readonly page: Page) {
this.dialog = page.getByRole('search')
this.input = this.dialog.locator('input[type="text"]')
this.input = this.dialog.getByRole('combobox')
this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' })
this.results = this.dialog.getByTestId('result-item')
this.filterOptions = this.dialog.getByTestId('filter-option')
}

View File

@@ -0,0 +1,72 @@
import type { Locator, Page } from '@playwright/test'
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
import { TestIds } from '@e2e/fixtures/selectors'
export class PublishDialog extends BaseDialog {
readonly nav: Locator
readonly footer: Locator
readonly savePrompt: Locator
readonly describeStep: Locator
readonly finishStep: Locator
readonly profilePrompt: Locator
readonly gateFlow: Locator
readonly nameInput: Locator
readonly descriptionTextarea: Locator
readonly tagsInput: Locator
readonly backButton: Locator
readonly nextButton: Locator
readonly publishButton: Locator
constructor(page: Page) {
super(page, TestIds.publish.dialog)
this.nav = this.root.getByTestId(TestIds.publish.nav)
this.footer = this.root.getByTestId(TestIds.publish.footer)
this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt)
this.describeStep = this.root.getByTestId(TestIds.publish.describeStep)
this.finishStep = this.root.getByTestId(TestIds.publish.finishStep)
this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt)
this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow)
this.nameInput = this.root.getByTestId(TestIds.publish.nameInput)
this.descriptionTextarea = this.describeStep.locator('textarea')
this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput)
this.backButton = this.footer.getByRole('button', { name: 'Back' })
this.nextButton = this.footer.getByRole('button', { name: 'Next' })
this.publishButton = this.footer.getByRole('button', {
name: 'Publish to ComfyHub'
})
}
// Uses showPublishDialog() via Vite-bundled lazy imports that work in both
// dev and production, rather than clicking through the UI.
async open(): Promise<void> {
await this.page.evaluate(async () => {
await window.app!.extensionManager.dialog.showPublishDialog()
})
await this.waitForVisible()
}
tagSuggestion(name: string): Locator {
return this.describeStep.getByText(name, { exact: true })
}
navStep(label: string): Locator {
return this.nav.getByRole('button', { name: label })
}
currentNavStep(): Locator {
return this.nav.locator('[aria-current="step"]')
}
async goNext(): Promise<void> {
await this.nextButton.click()
}
async goBack(): Promise<void> {
await this.backButton.click()
}
async goToStep(label: string): Promise<void> {
await this.navStep(label).click()
}
}

View File

@@ -1,9 +1,19 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type {
CreateAssetExportData,
CreateAssetExportResponse,
JobsListResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobsListRoutePattern = '**/api/jobs?*'
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetExportRoutePattern = '**/api/assets/export'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
@@ -158,12 +168,23 @@ function getExecutionDuration(job: RawJobListItem): number {
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null =
null
private assetExportRouteHandler: ((route: Route) => Promise<void>) | null =
null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private cloudAssetsResponse: ListAssetsResponse | null = null
private assetExportRequests: CreateAssetExportData['body'][] = []
private assetExportResponse: CreateAssetExportResponse | null = null
private importedFiles: string[] = []
private readonly jobDetailRouteHandlers = new Map<
string,
(route: Route) => Promise<void>
>()
constructor(private readonly page: Page) {}
@@ -240,6 +261,82 @@ export class AssetsHelper {
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockCloudAssets(response: ListAssetsResponse): Promise<void> {
this.cloudAssetsResponse = response
if (this.cloudAssetsRouteHandler) {
return
}
this.cloudAssetsRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.cloudAssetsResponse)
})
}
await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler)
}
async mockEmptyCloudAssets(): Promise<void> {
await this.mockCloudAssets({
assets: [],
total: 0,
has_more: false
})
}
async captureAssetExportRequests(
response: CreateAssetExportResponse = {
task_id: 'asset-export-task',
status: 'created'
}
): Promise<CreateAssetExportData['body'][]> {
this.assetExportRequests = []
this.assetExportResponse = response
if (this.assetExportRouteHandler) {
return this.assetExportRequests
}
this.assetExportRouteHandler = async (route: Route) => {
this.assetExportRequests.push(
route.request().postDataJSON() as CreateAssetExportData['body']
)
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify(this.assetExportResponse)
})
}
await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler)
return this.assetExportRequests
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
const pattern = `**/api/jobs/${encodeURIComponent(jobId)}`
const existingHandler = this.jobDetailRouteHandlers.get(pattern)
if (existingHandler) {
await this.page.unroute(pattern, existingHandler)
}
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(detail)
})
}
this.jobDetailRouteHandlers.set(pattern, handler)
await this.page.route(pattern, handler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
@@ -295,6 +392,9 @@ export class AssetsHelper {
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.cloudAssetsResponse = null
this.assetExportRequests = []
this.assetExportResponse = null
this.importedFiles = []
if (this.jobsRouteHandler) {
@@ -302,6 +402,22 @@ export class AssetsHelper {
this.jobsRouteHandler = null
}
if (this.cloudAssetsRouteHandler) {
await this.page.unroute(
assetsListRoutePattern,
this.cloudAssetsRouteHandler
)
this.cloudAssetsRouteHandler = null
}
if (this.assetExportRouteHandler) {
await this.page.unroute(
assetExportRoutePattern,
this.assetExportRouteHandler
)
this.assetExportRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
@@ -317,5 +433,10 @@ export class AssetsHelper {
)
this.deleteHistoryRouteHandler = null
}
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
await this.page.unroute(pattern, handler)
}
this.jobDetailRouteHandlers.clear()
}
}

View File

@@ -13,7 +13,11 @@ import type { Page } from '@playwright/test'
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
private readonly appUrl: string
constructor(private readonly page: Page) {
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
@@ -34,7 +38,7 @@ export class CloudAuthHelper {
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.goto(`${this.appUrl}/api/users`)
await this.page.evaluate(() => {
const MOCK_USER_DATA = {

View File

@@ -1,6 +1,10 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
GraphAddOptions,
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
@@ -39,6 +43,45 @@ export class NodeOperationsHelper {
})
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
})
}
/**
* Add a node to the graph by type.
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
* true and cursorPosition is provided, a synthetic MouseEvent is created
* as the dragEvent.
* @param cursorPosition - Client coordinates for ghost placement dragEvent
*/
async addNode(
type: string,
options?: Omit<GraphAddOptions, 'dragEvent'>,
cursorPosition?: Position
): Promise<NodeReference> {
const id = await this.page.evaluate(
([nodeType, opts, cursor]) => {
const node = window.LiteGraph!.createNode(nodeType)!
const addOpts: Record<string, unknown> = { ...opts }
if (opts?.ghost && cursor) {
addOpts.dragEvent = new MouseEvent('click', {
clientX: cursor.x,
clientY: cursor.y
})
}
window.app!.graph.add(node, addOpts as GraphAddOptions)
return node.id
},
[type, options ?? {}, cursorPosition ?? null] as const
)
return new NodeReference(id, this.comfyPage)
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)

View File

@@ -0,0 +1,231 @@
import type { Page, Route } from '@playwright/test'
import type {
AssetInfo,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
WorkflowPublishInfo
} from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
const DEFAULT_PROFILE: HubProfile = {
username: 'testuser',
display_name: 'Test User',
description: 'A test creator',
avatar_url: undefined
}
const DEFAULT_TAG_LABELS: HubLabelInfo[] = [
{ name: 'anime', display_name: 'anime', type: 'tag' },
{ name: 'upscale', display_name: 'upscale', type: 'tag' },
{ name: 'faceswap', display_name: 'faceswap', type: 'tag' },
{ name: 'img2img', display_name: 'img2img', type: 'tag' },
{ name: 'controlnet', display_name: 'controlnet', type: 'tag' }
]
const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = {
workflow_id: 'test-workflow-id-456',
share_id: 'test-share-id-123',
publish_time: new Date().toISOString(),
listed: true,
assets: []
}
const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
upload_url: 'https://mock-s3.example.com/upload',
public_url: 'https://mock-s3.example.com/asset.png',
token: 'mock-upload-token'
}
export class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(private readonly page: Page) {}
async mockProfile(profile: HubProfile | null): Promise<void> {
await this.addRoute('**/hub/profiles/me', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (profile === null) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(profile)
})
}
})
}
async mockTagLabels(
labels: HubLabelInfo[] = DEFAULT_TAG_LABELS
): Promise<void> {
const response: HubLabelListResponse = { labels }
await this.addRoute('**/hub/labels**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishStatus(
status: 'unpublished' | WorkflowPublishInfo
): Promise<void> {
await this.addRoute('**/userdata/*/publish', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
if (status === 'unpublished') {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(status)
})
}
})
}
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
const response: ShareableAssetsResponse = { assets }
await this.addRoute('**/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflow(
response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async mockPublishWorkflowError(
statusCode = 500,
message = 'Failed to publish workflow'
): Promise<void> {
await this.removeRoutes('**/hub/workflows')
await this.addRoute('**/hub/workflows', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue()
return
}
await route.fulfill({
status: statusCode,
contentType: 'application/json',
body: JSON.stringify({ message })
})
})
}
async mockUploadUrl(
response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE
): Promise<void> {
await this.addRoute('**/hub/assets/upload-url', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async setupDefaultMocks(options?: {
hasProfile?: boolean
hasPrivateAssets?: boolean
}): Promise<void> {
const { hasProfile = true, hasPrivateAssets = false } = options ?? {}
await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null)
await this.mockTagLabels()
await this.mockPublishStatus('unpublished')
await this.mockShareableAssets(
hasPrivateAssets
? [
{
id: 'asset-1',
name: 'my_model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: true
}
]
: []
)
await this.mockPublishWorkflow()
await this.mockUploadUrl()
}
async cleanup(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
}
private async addRoute(
pattern: string,
handler: (route: Route) => Promise<void>
): Promise<void> {
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
private async removeRoutes(pattern: string): Promise<void> {
const handlers = this.routeHandlers.filter(
(route) => route.pattern === pattern
)
for (const { handler } of handlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = this.routeHandlers.filter(
(route) => route.pattern !== pattern
)
}
}
export const publishFixture = comfyPageFixture.extend<{
publishApi: PublishApiHelper
publishDialog: PublishDialog
}>({
publishApi: async ({ comfyPage }, use) => {
const helper = new PublishApiHelper(comfyPage.page)
await use(helper)
await helper.cleanup()
},
publishDialog: async ({ comfyPage }, use) => {
await use(new PublishDialog(comfyPage.page))
}
})

View File

@@ -59,6 +59,9 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
@@ -209,6 +212,18 @@ export const TestIds = {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
publish: {
dialog: 'publish-dialog',
savePrompt: 'publish-save-prompt',
describeStep: 'publish-describe-step',
finishStep: 'publish-finish-step',
footer: 'publish-footer',
profilePrompt: 'publish-profile-prompt',
nav: 'publish-nav',
gateFlow: 'publish-gate-flow',
nameInput: 'publish-name-input',
tagsInput: 'publish-tags-input'
},
loading: {
overlay: 'loading-overlay'
},

View File

@@ -56,7 +56,13 @@ export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let entries
try {
entries = readdirSync('test-results', { withFileTypes: true })
} catch {
return
}
if (!entries.length) return
let tempFiles: string[]
try {

View File

@@ -1,6 +1,6 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type PromotedWidgetEntry = [string, string]
export type PromotedWidgetEntry = [string, string]
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -12,25 +13,35 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
const test = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
async ({ cloudAssetRequests, page }, use) => {
const pattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
}
await page.route(pattern, assetsRouteHandler)
await use()
await page.unroute(pattern)
await page.unroute(pattern, assetsRouteHandler)
},
{ auto: true }
]
@@ -42,23 +53,36 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
})
test('should use first cloud asset when server default is not in assets', async ({
cloudAssetRequests,
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
// Wait for the checkpoint asset query to complete and the existing widget
// to upgrade into asset mode before creating a fresh node. The current
// default node may keep a previously resolved value; what matters is that
// new nodes resolve against the cloud asset list after the fetch.
await expect
.poll(() =>
cloudAssetRequests.some((url) => {
const includeTags =
new URL(url).searchParams.get('include_tags') ?? ''
return includeTags.split(',').includes('checkpoints')
})
)
.toBe(true)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
comfyPage.page.evaluate((waitingForWidgetType) => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
return (
node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type ?? waitingForWidgetType
)
}, WAITING_FOR_WIDGET_TYPE),
{ timeout: 10_000 }
)
.toBe('asset')
@@ -81,15 +105,22 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
await expect
.poll(
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
comfyPage.page.evaluate(
({ id, waitingForWidgetType, waitingForWidgetValue }) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
if (widget?.type !== 'asset') return waitingForWidgetType
const val = String(widget?.value ?? '')
return val === 'Select model' ? waitingForWidgetValue : val
},
{
id: nodeId,
waitingForWidgetType: WAITING_FOR_WIDGET_TYPE,
waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE
}
),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)

View File

@@ -0,0 +1,306 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
const PUBLISH_FEATURE_FLAGS = {
comfyhub_upload_enabled: true,
comfyhub_profile_gate_enabled: true
} as const
async function saveAndOpenPublishDialog(
comfyPage: ComfyPage,
dialog: PublishDialog,
workflowName: string
): Promise<void> {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const overwriteDialog = comfyPage.page.locator(
'.p-dialog:has-text("Overwrite")'
)
// Bounded wait: point-in-time isVisible() can miss dialogs that open
// slightly after saveWorkflow() resolves.
try {
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
await comfyPage.confirmDialog.click('overwrite')
} catch {
// No overwrite dialog — workflow name was unique.
}
await dialog.open()
}
test.describe('Publish dialog - wizard navigation', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf')
})
test('opens on the Describe step by default', async ({ publishDialog }) => {
await expect(publishDialog.describeStep).toBeVisible()
await expect(publishDialog.nameInput).toBeVisible()
await expect(publishDialog.descriptionTextarea).toBeVisible()
})
test('pre-fills workflow name from active workflow', async ({
publishDialog
}) => {
await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/)
})
test('Next button navigates to Examples step', async ({ publishDialog }) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
// Examples step should show thumbnail toggle and upload area
await expect(
publishDialog.root.getByText('Select a thumbnail')
).toBeVisible()
})
test('Back button returns to Describe step from Examples', async ({
publishDialog
}) => {
await publishDialog.goNext()
await expect(publishDialog.describeStep).toBeHidden()
await publishDialog.goBack()
await expect(publishDialog.describeStep).toBeVisible()
})
test('navigates through all steps to Finish', async ({ publishDialog }) => {
await publishDialog.goNext() // → Examples
await publishDialog.goNext() // → Finish
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeVisible()
})
test('clicking nav step navigates directly', async ({ publishDialog }) => {
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.goToStep('Describe your workflow')
await expect(publishDialog.describeStep).toBeVisible()
})
test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => {
await comfyPage.page.keyboard.press('Escape')
await expect(publishDialog.root).toBeHidden()
})
})
test.describe('Publish dialog - Describe step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf')
})
test('allows editing the workflow name', async ({ publishDialog }) => {
await publishDialog.nameInput.clear()
await publishDialog.nameInput.fill('My Custom Workflow')
await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow')
})
test('allows editing the description', async ({ publishDialog }) => {
await publishDialog.descriptionTextarea.fill(
'A great workflow for anime art'
)
await expect(publishDialog.descriptionTextarea).toHaveValue(
'A great workflow for anime art'
)
})
test('displays tag suggestions from mocked API', async ({
publishDialog
}) => {
await expect(publishDialog.root.getByText('anime')).toBeVisible()
await expect(publishDialog.root.getByText('upscale')).toBeVisible()
})
// TODO(#11548): Tag click emits update:tags but the tag does not appear in
// the active list during E2E. Needs investigation of the parent state
// binding.
test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => {
await publishDialog.root.getByText('anime').click()
await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible()
})
})
test.describe('Publish dialog - Examples step', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf')
await publishDialog.goNext() // Navigate to Examples step
})
test('shows thumbnail type toggle options', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByText('Image', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Video', { exact: true })
).toBeVisible()
await expect(
publishDialog.root.getByText('Image comparison', { exact: true })
).toBeVisible()
})
test('shows example image upload tile', async ({ publishDialog }) => {
await expect(
publishDialog.root.getByRole('button', { name: 'Upload example image' })
).toBeVisible()
})
})
test.describe('Publish dialog - Finish step with profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf')
await publishDialog.goToStep('Finish publishing')
})
test('shows profile card with username', async ({ publishDialog }) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.root.getByText('@testuser')).toBeVisible()
await expect(publishDialog.root.getByText('Test User')).toBeVisible()
})
test('publish button is enabled when no private assets', async ({
publishDialog
}) => {
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - Finish step with private assets', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({
hasProfile: true,
hasPrivateAssets: true
})
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf')
await publishDialog.goToStep('Finish publishing')
})
test('publish button is disabled until assets acknowledged', async ({
publishDialog
}) => {
await expect(publishDialog.finishStep).toBeVisible()
await expect(publishDialog.publishButton).toBeDisabled()
const checkbox = publishDialog.finishStep.getByRole('checkbox')
await checkbox.check()
await expect(publishDialog.publishButton).toBeEnabled()
})
})
test.describe('Publish dialog - no profile', () => {
test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks({ hasProfile: false })
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-noprofile-wf'
)
await publishDialog.goToStep('Finish publishing')
})
test('shows profile creation prompt when user has no profile', async ({
publishDialog
}) => {
await expect(publishDialog.profilePrompt).toBeVisible()
await expect(
publishDialog.root.getByText('Create a profile to publish to ComfyHub')
).toBeVisible()
})
test('clicking create profile CTA shows profile creation form', async ({
publishDialog
}) => {
await publishDialog.root
.getByRole('button', { name: 'Create a profile' })
.click()
await expect(publishDialog.gateFlow).toBeVisible()
})
})
test.describe('Publish dialog - unsaved workflow', () => {
test.beforeEach(async ({ comfyPage, publishApi }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
await publishApi.setupDefaultMocks()
// Don't save workflow — open dialog on the default temporary workflow
})
test('shows save prompt for temporary workflow', async ({
comfyPage,
publishDialog
}) => {
// Create a new workflow to ensure it's temporary
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await publishDialog.open()
await expect(publishDialog.savePrompt).toBeVisible()
await expect(
publishDialog.root.getByText(
'You must save your workflow before publishing'
)
).toBeVisible()
// Nav should be hidden when save is required
await expect(publishDialog.nav).toBeHidden()
})
})
test.describe('Publish dialog - submission', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
})
test('successful publish closes dialog', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf')
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
await expect(publishDialog.root).toBeHidden({ timeout: 10_000 })
})
test('failed publish shows error toast', async ({
comfyPage,
publishApi,
publishDialog
}) => {
await publishApi.setupDefaultMocks({ hasProfile: true })
// Override publish mock with error response
await publishApi.mockPublishWorkflowError(500, 'Internal error')
await saveAndOpenPublishDialog(
comfyPage,
publishDialog,
'test-submit-fail-wf'
)
await publishDialog.goToStep('Finish publishing')
await expect(publishDialog.finishStep).toBeVisible()
await publishDialog.publishButton.click()
// Error toast should appear
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({
timeout: 10_000
})
// Dialog should remain open
await expect(publishDialog.root).toBeVisible()
})
})

View File

@@ -22,18 +22,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
const nodeRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
return { nodeId: nodeRef.id, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
@@ -80,7 +76,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
@@ -153,5 +148,127 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('moving ghost onto existing node and clicking places correctly', async ({
comfyPage
}) => {
// Get existing KSampler node from the default workflow
const [ksamplerRef] =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
const ksamplerPos = await ksamplerRef.getPosition()
const ksamplerSize = await ksamplerRef.getSize()
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
// Start ghost placement away from the existing node
const startX = 50
const startY = 50
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
await comfyPage.nextFrame()
const ghostRef = await comfyPage.nodeOps.addNode(
'VAEDecode',
{ ghost: true },
{ x: startX, y: startY }
)
await comfyPage.nextFrame()
// Move ghost onto the existing node
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
await comfyPage.nextFrame()
// Click to finalize — on top of the existing node
await comfyPage.page.mouse.click(targetX, targetY)
await comfyPage.nextFrame()
// Ghost should be placed (no longer ghost)
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
expect(ghostResult).not.toBeNull()
expect(ghostResult!.ghost).toBe(false)
// Ghost node should have moved from its start position toward where we clicked
const ghostPos = await ghostRef.getPosition()
expect(
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
).toBe(true)
// Existing node should NOT be selected
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
expect(selectedIds).not.toContain(ksamplerRef.id)
})
test(
'subgraph blueprint added from search box enters ghost mode',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.searchBoxV2.reload(comfyPage)
// Convert a node to a subgraph and publish it as a blueprint
const nodeRef = await comfyPage.nodeOps.getNodeRefById('3')
await nodeRef.click('title')
await comfyPage.nextFrame()
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph')
expect(subgraphNodes).toHaveLength(1)
const subgraphNode = subgraphNodes[0]
const blueprintName = `ghost-test-${Date.now()}`
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await comfyPage.toast.closeToasts(1)
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
// Open v2 search box and search for the published blueprint
await comfyPage.canvasOps.doubleClick()
const { searchBoxV2 } = comfyPage
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill(blueprintName)
await expect(searchBoxV2.results.first()).toBeVisible()
// Click the result to add the node (v2 search box uses ghost mode)
await searchBoxV2.results.first().click()
await comfyPage.nextFrame()
// A new node should exist on the graph in ghost mode
const nodeCountAfter = await comfyPage.nodeOps.getGraphNodesCount()
expect(nodeCountAfter).toBe(nodeCountBefore + 1)
const ghostNodeId = await comfyPage.page.evaluate(() => {
return window.app!.canvas.state.ghostNodeId
})
expect(ghostNodeId).not.toBeNull()
const ghostState = await getNodeById(comfyPage, ghostNodeId!)
expect(ghostState).not.toBeNull()
expect(ghostState!.ghost).toBe(true)
// Wait for search box to close, then click to confirm placement
await expect(searchBoxV2.input).toBeHidden()
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page.mouse.click(
Math.round(viewport.width / 2),
Math.round(viewport.height / 2)
)
await comfyPage.nextFrame()
const afterPlace = await getNodeById(comfyPage, ghostNodeId!)
expect(afterPlace).not.toBeNull()
expect(afterPlace!.ghost).toBe(false)
}
)
})
}

View File

@@ -56,7 +56,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
})
test.describe('Category navigation', () => {
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
test('Bookmarked filter shows only bookmarked nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'KSampler'
@@ -66,7 +68,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.canvasOps.doubleClick()
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.categoryButton('favorites').click()
await searchBoxV2.filterBarButton('Bookmarked').click()
await expect(searchBoxV2.results).toHaveCount(1)
await expect(searchBoxV2.results.first()).toContainText('KSampler')
@@ -101,7 +103,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
// Type to narrow and select MODEL
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()

View File

@@ -97,7 +97,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
// Apply Input filter with MODEL type
await searchBoxV2.filterBarButton('Input').click()
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
await searchBoxV2.input.fill('MODEL')
await searchBoxV2.filterSearch.fill('MODEL')
await searchBoxV2.filterOptions
.filter({ hasText: 'MODEL' })
.first()

View File

@@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transform1)
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
await expect(cursor).toBeHidden()
})
@@ -187,7 +187,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
box.y + box.height * 0.5,
{ steps: 10 }
)
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
await comfyPage.page.mouse.move(
box.x + box.width + 20,
box.y + box.height * 0.5
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
@@ -408,6 +411,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
// default 512 + slider step 64 = 576
.toBe(576)
})
@@ -493,6 +497,29 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBe(false)
}
)
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should start empty'
})
.toBe(false)
await painterWidget
.getByTestId('painter-clear-button')
.dispatchEvent('click')
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'canvas should still be empty after clearing empty canvas'
})
.toBe(false)
})
})
test.describe('Serialization', () => {
@@ -560,36 +587,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
@@ -604,18 +601,318 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
test.describe('Serialization — unchanged canvas', () => {
test(
'Unchanged canvas does not re-upload on second serialization',
{ tag: '@slow' },
async ({ comfyPage }) => {
let uploadCount = 0
await comfyPage.page.route('**/upload/image', async (route) => {
uploadCount++
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockResponse)
})
})
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await drawStroke(comfyPage.page, canvas)
await triggerSerialization(comfyPage.page)
expect(uploadCount, 'first serialization should upload once').toBe(1)
await triggerSerialization(comfyPage.page)
expect(
uploadCount,
'second serialization without new drawing should not re-upload'
).toBe(1)
}
)
})
test.describe('Settings persistence', () => {
test('Tool selection is saved to node properties', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
| string
| undefined
}),
{ message: 'painterTool property should update to eraser' }
)
.toBe('eraser')
})
test('Brush size change is saved to node properties', async ({
comfyPage
}) => {
const sizeRow = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
.getByTestId('painter-size-row')
const sizeSlider = sizeRow.getByRole('slider')
await expect(
sizeRow.getByTestId('painter-size-value'),
'brush size should start at default 20'
).toHaveText('20')
await sizeSlider.focus()
for (let i = 0; i < 10; i++) {
await sizeSlider.press('ArrowRight')
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
return graph?._nodes_by_id?.['1']?.properties
?.painterBrushSize as number | undefined
}),
{ message: 'painterBrushSize property should update to 30' }
)
.toBe(30)
})
})
test('Controls collapse to single column in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
).toBeVisible()
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
if (node) {
node.size = [200, 400]
window.app!.canvas.setDirty(true, true)
}
})
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
})
test('Multiple sequential strokes at different positions all accumulate', async ({
comfyPage
}) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true
}
return false
}, yFraction)
await expect
.poll(() => hasContentAtRow(0.25), {
message: 'top stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.5), {
message: 'middle stroke should be present'
})
.toBe(true)
await expect
.poll(() => hasContentAtRow(0.75), {
message: 'bottom stroke should be present'
})
.toBe(true)
})
})
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },
() => {
test.setTimeout(60_000)
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
})
test('Width, height, and bg_color controls hide when input is connected', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
await expect(
painterWidget.getByTestId('painter-width-row'),
'width row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-height-row'),
'height row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-bg-color-row'),
'background color row should be hidden when input is connected'
).toBeHidden()
await expect(
painterWidget.getByTestId('painter-dimension-text'),
'dimension text should be visible when input is connected'
).toBeVisible()
})
test('Canvas resizes to match input image dimensions after execution', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{
message: 'input image should be fully decoded',
timeout: 30_000
}
)
.toBe(true)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas width should match input image natural width'
})
.toBe(nw)
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
message: 'canvas height should match input image natural height'
})
.toBe(nh)
})
test('Drawing over input image produces content on canvas', async ({
comfyPage
}) => {
await comfyPage.runButton.click()
const node = comfyPage.vueNodes.getNodeLocator('1')
const img = node.locator('.widget-expands img')
await expect(
img,
'input image should appear after execution'
).toBeVisible({
timeout: 30_000
})
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
),
{ message: 'input image should be fully decoded', timeout: 30_000 }
)
.toBe(true)
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
const canvas = node.locator('.widget-expands canvas')
await expect
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
message: 'canvas should resize to match input image width',
timeout: 15_000
})
.toBe(nw)
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
// intercepts coordinate-based hit testing from page.mouse
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not found')
const startX = box.x + box.width * 0.3
const endX = box.x + box.width * 0.7
const midY = box.y + box.height * 0.5
const pointerOpts = {
bubbles: true,
cancelable: true,
pointerId: 1,
button: 0,
isPrimary: true
}
await canvas.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: midY
})
for (let i = 1; i <= 10; i++) {
await canvas.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + (endX - startX) * (i / 10),
clientY: midY
})
}
await canvas.dispatchEvent('pointerup', {
...pointerOpts,
clientX: endX,
clientY: midY
})
await expect
.poll(() => hasCanvasContent(canvas), {
message: 'drawing over input image should produce canvas content'
})
.toBe(true)
})
}
)

View File

@@ -99,5 +99,58 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
)
await expect(downloadButton.first()).toBeVisible()
})
test('Should render Download all and Refresh actions for one downloadable model', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
).toBeVisible()
})
test('Should clear resolved missing model when Refresh is clicked', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await comfyPage.page.route(/\/object_info$/, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
})
})

View File

@@ -5,7 +5,10 @@ import {
createMockJob,
createMockJobs
} from '@e2e/fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// ---------------------------------------------------------------------------
// Shared fixtures
@@ -62,6 +65,37 @@ const SAMPLE_IMPORTED_FILES = [
'audio_clip.wav'
]
const JOB_GAMMA_DETAIL: JobDetail = {
...SAMPLE_JOBS[2],
outputs: {
'3': {
images: [
{
filename: 'abstract_art.png',
subfolder: '',
type: 'output'
},
{
filename: 'abstract_art_alt.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({
mockCloudAssetSidebarData: async ({ comfyPage }, use) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockEmptyCloudAssets()
await use()
await comfyPage.assets.clearMocks()
}
})
// ==========================================================================
// 1. Empty states
// ==========================================================================
@@ -633,6 +667,96 @@ test.describe('Assets sidebar - bulk actions', () => {
})
})
cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
cloudTest(
'Single job selection uses preserve naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
await expect(tab.downloadSelectedButton).toBeVisible()
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids).toEqual(['job-gamma'])
expect(payload.job_asset_name_filters).toBeUndefined()
expect(payload.naming_strategy).toBe('preserve')
}
)
cloudTest(
'Multiple selected assets from one job use preserve naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
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.poll(() => tab.assetCards.count()).toBe(2)
await tab.assetCards.first().click()
await comfyPage.page.keyboard.down('Control')
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids).toEqual(['job-gamma'])
expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual(
['abstract_art.png', 'abstract_art_alt.png']
)
expect(payload.naming_strategy).toBe('preserve')
}
)
cloudTest(
'Multiple selected jobs use job-time naming strategy',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await tab.assetCards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta'])
expect(payload.job_asset_name_filters).toBeUndefined()
expect(payload.naming_strategy).toBe('group_by_job_time')
}
)
})
// ==========================================================================
// 9. Pagination
// ==========================================================================

View File

@@ -1,39 +1,73 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const getPromotedHostWidgetValues = async (
comfyPage: ComfyPage,
nodeIds: string[]
) => {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) {
expect(widgets.length).toBeGreaterThan(0)
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
}, nodeIds)
}
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
expect(results).toEqual(widgets.map(() => true))
}
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (
!node ||
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -86,54 +120,434 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test.describe('Legacy prefixed proxyWidget normalization', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
test.describe('Deterministic proxyWidgets Hydrate', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const workflowName =
'subgraphs/subgraph-multi-instance-promoted-text-values'
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
expect(initialValues.map(({ values }) => values[0])).toEqual(
expectedValues
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
hostNodeIds
'11',
afterFirst
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
await comfyPage.subgraph.serializeAndReload()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
let previousUseNewMenu: unknown
test.beforeEach(async ({ comfyPage }) => {
previousUseNewMenu =
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.UseNewMenu',
previousUseNewMenu
)
})
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
await comfyPage.subgraph.serializeAndReload()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
await comfyPage.subgraph.serializeAndReload()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Duplicate ID Remapping', () => {
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await expect
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
timeout: 5_000
})
.toEqual(beforeSnapshot)
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
const checkEndpoint = (
label: string,
kind: 'origin_id' | 'target_id',
id: number | string,
g: typeof graph
): string | null => {
if (isSentinelNodeId(id)) return null
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
return `${label}: ${kind} ${id} invalid or not found`
}
return null
}
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
checkEndpoint(label, 'origin_id', link.origin_id, g),
checkEndpoint(label, 'target_id', link.target_id, g)
].filter((e): e is string => e !== null)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page
)
try {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
comfyExpect(warnings).toEqual([])
} finally {
dispose()
}
})
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
await expect(textarea).toBeDisabled()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -285,9 +285,12 @@ export default defineConfig([
'Use vi.mock() with vi.hoisted() instead of vi.doMock(). See docs/testing/vitest-patterns.md'
}
],
// Tests routinely define stub and harness components side-by-side with the
// system under test, which is a distinct use case from production SFCs.
'vue/one-component-per-file': 'off'
// Tests routinely define stub and harness components side-by-side with
// the system under test and stub emits for documentation only — these
// production-SFC rules are noise in a test file.
'vue/one-component-per-file': 'off',
'vue/no-reserved-component-names': 'off',
'vue/no-unused-emit-declarations': 'off'
}
},
{

View File

@@ -17,6 +17,9 @@ const config: KnipConfig = {
entry: ['src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/design-system': {
project: ['src/**/*.{css,js,ts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},

View File

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

View File

@@ -589,8 +589,6 @@
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {

View File

@@ -3825,14 +3825,14 @@ export type CreateAssetExportData = {
/**
* Strategy for naming files in the ZIP:
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
* - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png")
* - group_by_job_time: Group assets by job execution time as parent directories
* - preserve: Use original asset names, skip duplicates (first one wins)
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
*
*/
naming_strategy?:
| 'group_by_job_id'
| 'prepend_job_id'
| 'group_by_job_time'
| 'preserve'
| 'asset_id'
/**

View File

@@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({
job_ids: z.array(z.string()).optional(),
asset_ids: z.array(z.string()).optional(),
naming_strategy: z
.enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id'])
.enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
}),

View File

@@ -14495,7 +14495,7 @@ export interface components {
* @description The ID of the model to call
* @enum {string}
*/
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit";
model: "wan2.5-t2v-preview" | "wan2.5-i2v-preview" | "wan2.6-t2v" | "wan2.6-i2v" | "wan2.6-r2v" | "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v" | "wan2.7-videoedit" | "happyhorse-1.0-t2v" | "happyhorse-1.0-i2v" | "happyhorse-1.0-r2v" | "happyhorse-1.0-video-edit";
/** @description Enter basic information, such as prompt words, etc. */
input: {
/**

View File

@@ -202,6 +202,28 @@ describe('formatUtil', () => {
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
})
it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
})
it('should not match across line breaks', () => {
const result = highlightQuery('ge\nto', 'geto', false)
expect(result).toBe('ge\nto')
})
it('should not match across tabs', () => {
const result = highlightQuery('ge\tto', 'geto', false)
expect(result).toBe('ge\tto')
})
it('should not match across multiple spaces', () => {
const result = highlightQuery('ge to', 'geto', false)
expect(result).toBe('ge to')
})
})
describe('getFilenameDetails', () => {

View File

@@ -74,10 +74,14 @@ export function highlightQuery(
text = DOMPurify.sanitize(text)
}
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Escape special regex characters, then join with an optional single
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
// highlighted without spanning tabs, newlines, or multi-space gaps.
const pattern = Array.from(query)
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[ ]?')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -1,9 +1,17 @@
import { clsx } from 'clsx'
import type { ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { extendTailwindMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': ['text-xxs', 'text-xxxs']
}
}
})
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -26,7 +26,7 @@ const ScrubableNumberInputStub = defineComponent({
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<input

View File

@@ -1,5 +1,3 @@
/* eslint-disable vue/no-reserved-component-names */
/* eslint-disable vue/no-unused-emit-declarations */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'

View File

@@ -1,4 +1,3 @@
/* eslint-disable vue/no-reserved-component-names */
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -100,7 +99,7 @@ const WidgetBoundingBoxStub = defineComponent({
modelValue: { type: Object, default: () => ({}) },
disabled: { type: Boolean, default: false }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `<div data-testid="bbox-child"
:data-disabled="String(disabled)"

View File

@@ -0,0 +1,28 @@
<template>
<span
:class="
cn(
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span
v-if="rest"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
defineProps<{
text: string
rest?: string
}>()
</script>

View File

@@ -1,9 +1,14 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
@@ -18,21 +23,21 @@
<!-- Category Path -->
<p
v-if="showCategoryPath && nodeDef.category"
class="-mt-1 text-xs text-muted-foreground"
class="-mt-1 truncate text-xs text-muted-foreground"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
{{ categoryPath }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 empty:hidden">
<NodePricingBadge :node-def="nodeDef" />
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
<NodeProviderBadge :node-def="nodeDef" />
</div>
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-2xs/normal font-normal text-muted-foreground"
class="m-0 max-h-[30vh] overflow-y-auto text-2xs/normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>
@@ -99,17 +104,20 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
showCategoryPath = false,
scaleFactor = 0.5
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()
const previewContainerRef = ref<HTMLElement>()
@@ -118,11 +126,13 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
const scaledHeight = entry.contentRect.height * scaleFactor
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
const inputs = computed(() => {
if (!nodeDef.inputs) return []
return Object.entries(nodeDef.inputs)

View File

@@ -1,18 +1,13 @@
<template>
<BadgePill
v-if="nodeDef.api_node"
v-show="priceLabel"
:text="priceLabel"
icon="icon-[comfy--credits]"
border-style="#f59e0b"
filled
/>
<span v-if="nodeDef.api_node && priceLabel">
<CreditBadge :text="priceLabel" />
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BadgePill from '@/components/common/BadgePill.vue'
import CreditBadge from '@/components/node/CreditBadge.vue'
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -42,7 +42,7 @@ const RangeEditorStub = defineComponent({
histogram: { type: Object, default: null },
display: { type: String, default: '' }
},
// eslint-disable-next-line vue/no-unused-emit-declarations
emits: ['update:modelValue'],
template: `
<div data-testid="range-editor"

View File

@@ -5,6 +5,8 @@ import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/scripts/app', () => ({
app: {
@@ -50,6 +52,12 @@ describe('TabErrors.vue', () => {
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
missingModels: {
missingModelsTitle: 'Missing Models',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
@@ -82,7 +90,7 @@ describe('TabErrors.vue', () => {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button><slot /></button>'
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
@@ -241,4 +249,58 @@ describe('TabErrors.vue', () => {
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
})
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
} satisfies MissingModelCandidate
const { user } = renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
await user.click(screen.getByTestId('missing-model-header-refresh'))
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'downloadable.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/downloadable.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
})
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -101,18 +101,6 @@
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
downloadableModels.length > 1
"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
@click.stop="downloadAllModels"
>
{{ downloadAllLabel }}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
@@ -128,6 +116,47 @@
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</div>
</template>
@@ -238,14 +267,10 @@ import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.v
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import {
downloadModel,
isModelDownloadable
} from '@/platform/missingModel/missingModelDownload'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
@@ -267,6 +292,7 @@ const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
@@ -307,6 +333,23 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery, t)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
)
function handleMissingModelRefresh() {
void missingModelStore.refreshMissingModels()
}
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
@@ -321,45 +364,6 @@ const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
const missingModelStore = useMissingModelStore()
const downloadableModels = computed(() => {
if (isCloud) return []
return missingModelGroups.value.flatMap((group) =>
group.models
.filter(
(m) =>
m.representative.url &&
m.representative.directory &&
isModelDownloadable({
name: m.representative.name,
url: m.representative.url,
directory: m.representative.directory
})
)
.map((m) => ({
name: m.representative.name,
url: m.representative.url!,
directory: m.representative.directory!
}))
)
})
const downloadAllLabel = computed(() => {
const base = t('rightSidePanel.missingModels.downloadAll')
const total = downloadableModels.value.reduce(
(sum, m) => sum + (missingModelStore.fileSizes[m.url] ?? 0),
0
)
return total > 0 ? `${base} (${formatSize(total)})` : base
})
function downloadAllModels() {
for (const model of downloadableModels.value) {
downloadModel(model, missingModelStore.folderPaths)
}
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))

View File

@@ -8,15 +8,17 @@ import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionPolicy'
import {
getPromotableWidgets,
isRecommendedWidget
} from '@/core/graph/subgraph/promotionPolicy'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -7,7 +7,7 @@
:pt="{
root: {
class: useSearchBoxV2
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
@@ -36,7 +36,9 @@
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
:scale-factor="0.625"
show-category-path
inert
class="absolute top-0 left-full ml-3"
/>
</div>

View File

@@ -1,7 +1,6 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
@@ -11,12 +10,12 @@ import {
} from '@/components/searchbox/v2/__test__/testUtils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
set: vi.fn()
}))
}))
type SidebarProps = Partial<{
selectedCategory: string
hidePresets: boolean
rootLabel: string
rootKey: string
}>
describe('NodeSearchCategorySidebar', () => {
beforeEach(() => {
@@ -24,35 +23,30 @@ describe('NodeSearchCategorySidebar', () => {
setupTestPinia()
})
async function createRender(props = {}) {
function createRender(props: SidebarProps = {}) {
const user = userEvent.setup()
const onUpdateSelectedCategory = vi.fn()
const baseProps = { selectedCategory: 'most-relevant', ...props }
let currentProps = { ...baseProps }
let rerenderFn: (
p: typeof baseProps & Record<string, unknown>
) => void = () => {}
function makeProps(overrides = {}) {
const merged = { ...currentProps, ...overrides }
return {
...merged,
'onUpdate:selectedCategory': (val: string) => {
onUpdateSelectedCategory(val)
currentProps = { ...currentProps, selectedCategory: val }
rerenderFn(makeProps())
}
}
const onUpdateSelectedCategory = vi.fn<(value: string) => void>()
const initialProps: SidebarProps & { selectedCategory: string } = {
selectedCategory: 'most-relevant',
...props
}
const result = render(NodeSearchCategorySidebar, {
props: makeProps(),
props: {
...initialProps,
'onUpdate:selectedCategory': onUpdateSelectedCategory
},
global: { plugins: [testI18n] }
})
rerenderFn = (p) => result.rerender(p)
await nextTick()
return { user, onUpdateSelectedCategory }
const rerender = (overrides: SidebarProps) =>
result.rerender({
...initialProps,
...overrides,
'onUpdate:selectedCategory': onUpdateSelectedCategory
})
return { user, onUpdateSelectedCategory, rerender }
}
async function clickCategory(
@@ -60,40 +54,26 @@ describe('NodeSearchCategorySidebar', () => {
text: string,
exact = false
) {
const buttons = screen.getAllByRole('button')
const btn = buttons.find((b) =>
const candidates = [
...screen.queryAllByRole('button'),
...screen.queryAllByRole('treeitem')
]
const btn = candidates.find((b) =>
exact ? b.textContent?.trim() === text : b.textContent?.includes(text)
)
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
await user.click(btn!)
await nextTick()
}
describe('preset categories', () => {
it('should render all preset categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
await createRender()
it('should render Most relevant preset category', () => {
createRender()
expect(screen.getByText('Most relevant')).toBeInTheDocument()
expect(screen.getByText('Recents')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(screen.getByText('Essentials')).toBeInTheDocument()
expect(screen.getByText('Blueprints')).toBeInTheDocument()
expect(screen.getByText('Partner')).toBeInTheDocument()
expect(screen.getByText('Comfy')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
})
it('should mark the selected preset category as selected', async () => {
await createRender({ selectedCategory: 'most-relevant' })
it('should mark the selected preset category as selected', () => {
createRender({ selectedCategory: 'most-relevant' })
expect(screen.getByTestId('category-most-relevant')).toHaveAttribute(
'aria-current',
@@ -102,26 +82,30 @@ describe('NodeSearchCategorySidebar', () => {
})
it('should emit update:selectedCategory when preset is clicked', async () => {
const { user, onUpdateSelectedCategory } = await createRender({
const { user, onUpdateSelectedCategory } = createRender({
selectedCategory: 'most-relevant'
})
await clickCategory(user, 'Favorites')
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('favorites')
await screen.findByText('sampling')
await clickCategory(user, 'sampling')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
})
})
describe('category tree', () => {
it('should render top-level categories from node definitions', async () => {
it('should render top-level categories from node definitions', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'loaders' }),
createMockNodeDef({ name: 'Node3', category: 'conditioning' })
])
await nextTick()
await createRender()
createRender()
expect(screen.getByText('sampling')).toBeInTheDocument()
expect(screen.getByText('loaders')).toBeInTheDocument()
@@ -132,9 +116,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const { user, onUpdateSelectedCategory } = createRender()
await clickCategory(user, 'sampling')
@@ -147,20 +130,18 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
await nextTick()
const { user } = await createRender()
const { user } = createRender()
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
await clickCategory(user, 'sampling')
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
expect(screen.getByText('basic')).toBeInTheDocument()
})
await screen.findByText('advanced')
expect(screen.getByText('basic')).toBeInTheDocument()
})
it('should collapse sibling category when another is expanded', async () => {
@@ -170,15 +151,12 @@ describe('NodeSearchCategorySidebar', () => {
createMockNodeDef({ name: 'Node3', category: 'image' }),
createMockNodeDef({ name: 'Node4', category: 'image/upscale' })
])
await nextTick()
const { user } = await createRender()
const { user } = createRender()
// Expand sampling
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await screen.findByText('advanced')
// Expand image — sampling should collapse
await clickCategory(user, 'image', true)
@@ -192,17 +170,15 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit update:selectedCategory when subcategory is clicked', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const { user, onUpdateSelectedCategory } = createRender()
// Expand sampling category
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await screen.findByText('advanced')
// Click on advanced subcategory
await clickCategory(user, 'advanced')
@@ -213,13 +189,12 @@ describe('NodeSearchCategorySidebar', () => {
})
describe('category selection highlighting', () => {
it('should mark selected top-level category as selected', async () => {
it('should mark selected top-level category as selected', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
await createRender({ selectedCategory: 'sampling' })
createRender({ selectedCategory: 'sampling' })
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
'aria-current',
@@ -230,19 +205,17 @@ describe('NodeSearchCategorySidebar', () => {
it('should emit selected subcategory when expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender({
const { user, onUpdateSelectedCategory } = createRender({
selectedCategory: 'most-relevant'
})
// Expand and click subcategory
await clickCategory(user, 'sampling', true)
await waitFor(() => {
expect(screen.getByText('advanced')).toBeInTheDocument()
})
await screen.findByText('advanced')
await clickCategory(user, 'advanced')
const calls = onUpdateSelectedCategory.mock.calls
@@ -250,15 +223,121 @@ describe('NodeSearchCategorySidebar', () => {
})
})
it('should support deeply nested categories (3+ levels)', async () => {
describe('hidePresets prop', () => {
it('should hide preset categories when hidePresets is true', () => {
createRender({ hidePresets: true })
expect(screen.queryByText('Most relevant')).not.toBeInTheDocument()
})
})
it('should emit category without root/ prefix', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
const { user, onUpdateSelectedCategory } = createRender()
await clickCategory(user, 'sampling')
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
})
describe('rootLabel wrapping', () => {
it('should wrap multiple top-level categories under rootLabel key', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'sampling' }),
createMockNodeDef({ name: 'N2', category: 'loaders' })
])
const { user, onUpdateSelectedCategory } = createRender({
rootLabel: 'Extensions',
rootKey: 'custom'
})
expect(screen.getByText('Extensions')).toBeInTheDocument()
// Expand the wrapper root
const customBtn = screen.getByTestId('category-custom')
expect(customBtn).toBeInTheDocument()
await user.click(customBtn)
await waitFor(() => {
expect(screen.getByText('sampling')).toBeInTheDocument()
expect(screen.getByText('loaders')).toBeInTheDocument()
})
// Subcategories should be prefixed with the root key
expect(screen.getByTestId('category-custom/sampling')).toBeInTheDocument()
await user.click(screen.getByTestId('category-custom/sampling'))
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
})
it('should derive root key from rootLabel when rootKey is not provided', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'sampling' }),
createMockNodeDef({ name: 'N2', category: 'loaders' })
])
const { user, onUpdateSelectedCategory } = createRender({
rootLabel: 'Custom'
})
await user.click(screen.getByTestId('category-custom'))
await user.click(await screen.findByTestId('category-custom/sampling'))
const calls = onUpdateSelectedCategory.mock.calls
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
})
})
describe('external selectedCategory updates', () => {
it('should update expanded state when selectedCategory changes externally', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
const { rerender } = createRender({
selectedCategory: 'most-relevant'
})
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
await rerender({ selectedCategory: 'sampling' })
await screen.findByText('advanced')
})
})
it('should emit autoExpand when there is a single root category', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' })
])
const onAutoExpand = vi.fn()
render(NodeSearchCategorySidebar, {
props: {
selectedCategory: 'most-relevant',
onAutoExpand: onAutoExpand
},
global: { plugins: [testI18n] }
})
expect(onAutoExpand).toHaveBeenCalledWith('api')
})
it('should support deeply nested categories', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'api' }),
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
createMockNodeDef({ name: 'Node3', category: 'api/image/BFL' })
])
await nextTick()
const { user, onUpdateSelectedCategory } = await createRender()
const { user, onUpdateSelectedCategory } = createRender()
// Only top-level visible initially
expect(screen.getByText('api')).toBeInTheDocument()
@@ -267,16 +346,12 @@ describe('NodeSearchCategorySidebar', () => {
// Expand api
await clickCategory(user, 'api', true)
await waitFor(() => {
expect(screen.getByText('image')).toBeInTheDocument()
})
await screen.findByText('image')
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
// Expand image
await clickCategory(user, 'image', true)
await waitFor(() => {
expect(screen.getByText('BFL')).toBeInTheDocument()
})
await screen.findByText('BFL')
// Click BFL and verify emission
await clickCategory(user, 'BFL', true)
@@ -285,16 +360,179 @@ describe('NodeSearchCategorySidebar', () => {
expect(calls[calls.length - 1]).toEqual(['api/image/BFL'])
})
it('should emit category without root/ prefix', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' })
])
await nextTick()
describe('keyboard navigation', () => {
it('should expand a collapsed tree node on ArrowRight', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
const { user, onUpdateSelectedCategory } = await createRender()
const { user, onUpdateSelectedCategory } = createRender()
await clickCategory(user, 'sampling')
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
const samplingBtn = screen.getByTestId('category-sampling')
samplingBtn.focus()
await user.keyboard('{ArrowRight}')
// Should have emitted select for sampling, expanding it
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
})
it('should collapse an expanded tree node on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
const { user } = createRender()
// First expand sampling by clicking
await clickCategory(user, 'sampling', true)
await screen.findByText('advanced')
const samplingBtn = screen.getByTestId('category-sampling')
samplingBtn.focus()
await user.keyboard('{ArrowLeft}')
// Collapse toggles internal state; children should be hidden
await waitFor(() => {
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
})
})
it('should focus first child on ArrowRight when already expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
const { user } = createRender()
await clickCategory(user, 'sampling', true)
await screen.findByText('advanced')
const samplingBtn = screen.getByTestId('category-sampling')
samplingBtn.focus()
await user.keyboard('{ArrowRight}')
await waitFor(() => {
expect(screen.getByTestId('category-sampling/advanced')).toHaveFocus()
})
})
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
const { user } = createRender()
await clickCategory(user, 'sampling', true)
await screen.findByText('advanced')
screen.getByTestId('category-sampling/advanced').focus()
await user.keyboard('{ArrowLeft}')
await waitFor(() => {
expect(screen.getByTestId('category-sampling')).toHaveFocus()
})
})
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({
name: 'Node2',
category: 'sampling/custom_sampling'
}),
createMockNodeDef({
name: 'Node3',
category: 'sampling/custom_sampling/child'
}),
createMockNodeDef({ name: 'Node4', category: 'loaders' })
])
const { user } = createRender()
// Step 1: Expand sampling
await clickCategory(user, 'sampling', true)
await screen.findByText('custom_sampling')
// Step 2: Expand custom_sampling
await clickCategory(user, 'custom_sampling', true)
await screen.findByText('child')
// Step 3: Navigate back to sampling (keyboard focus only)
const samplingBtn = screen.getByTestId('category-sampling')
samplingBtn.focus()
// Step 4: Press left on sampling
await user.keyboard('{ArrowLeft}')
// Sampling should collapse entirely — custom_sampling should not be visible
await waitFor(() => {
expect(screen.queryByText('custom_sampling')).not.toBeInTheDocument()
})
})
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'N1', category: 'a' }),
createMockNodeDef({ name: 'N2', category: 'a/b' }),
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
createMockNodeDef({ name: 'N5', category: 'other' })
])
const { user } = createRender()
// Expand a → a/b → a/b/c
await clickCategory(user, 'a', true)
await screen.findByText('b')
await clickCategory(user, 'b', true)
await screen.findByText('c')
await clickCategory(user, 'c', true)
await screen.findByText('d')
// Focus level 2 (a/b) and press ArrowLeft
const bBtn = screen.getByTestId('category-a/b')
bBtn.focus()
await user.keyboard('{ArrowLeft}')
// Level 2 and below should collapse, but level 1 (a) stays expanded
// so 'b' is still visible but 'c' and 'd' are not
await waitFor(() => {
expect(screen.queryByText('c')).not.toBeInTheDocument()
})
expect(screen.getByText('b')).toBeInTheDocument()
expect(screen.queryByText('d')).not.toBeInTheDocument()
})
it('should set aria-expanded on tree nodes with children', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
createRender()
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
'aria-expanded',
'false'
)
// Leaf node should not have aria-expanded
expect(screen.getByTestId('category-loaders')).not.toHaveAttribute(
'aria-expanded'
)
})
})
})

View File

@@ -1,105 +1,109 @@
<template>
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
<RovingFocusGroup
as="div"
orientation="vertical"
:loop="true"
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
>
<!-- Preset categories -->
<div class="flex flex-col px-1">
<button
<div v-if="!hidePresets" class="flex flex-col px-3">
<RovingFocusItem
v-for="preset in topCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
as-child
>
{{ preset.label }}
</button>
</div>
<!-- Source categories -->
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
<button
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
<Button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</Button>
</RovingFocusItem>
</div>
<!-- Category tree -->
<div class="flex flex-col px-1">
<div
role="tree"
:aria-label="t('g.category')"
:class="
cn(
'flex flex-col px-3',
!hidePresets && 'mt-2 border-t border-border-subtle pt-2'
)
"
>
<NodeSearchCategoryTreeNode
v-for="category in categoryTree"
:key="category.key"
:node="category"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
@select="selectCategory"
@collapse="collapseCategory"
/>
</div>
</div>
</RovingFocusGroup>
</template>
<script lang="ts">
export const DEFAULT_CATEGORY = 'most-relevant'
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import Button from '@/components/ui/button/Button.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { cn } from '@comfyorg/tailwind-utils'
const {
hideChevrons = false,
hidePresets = false,
nodeDefs,
rootLabel,
rootKey
} = defineProps<{
hideChevrons?: boolean
hidePresets?: boolean
nodeDefs?: ComfyNodeDefImpl[]
rootLabel?: string
rootKey?: string
}>()
const selectedCategory = defineModel<string>('selectedCategory', {
required: true
})
const emit = defineEmits<{
autoExpand: [key: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
])
const hasEssentialNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
)
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})
const categoryTree = computed<CategoryNode[]>(() => {
const tree = nodeOrganizationService.organizeNodes(
nodeDefStore.visibleNodeDefs,
{ groupBy: 'category' }
)
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
const tree = nodeOrganizationService.organizeNodes(defs, {
groupBy: 'category'
})
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
@@ -114,28 +118,84 @@ const categoryTree = computed<CategoryNode[]>(() => {
}
}
return (tree.children ?? [])
const nodes = (tree.children ?? [])
.filter((node): node is TreeNode => !node.leaf)
.map(mapNode)
if (rootLabel && nodes.length > 1) {
const key = rootKey ?? rootLabel.toLowerCase()
function prefixKeys(node: CategoryNode): CategoryNode {
return {
key: key + '/' + node.key,
label: node.label,
...(node.children?.length
? { children: node.children.map(prefixKeys) }
: {})
}
}
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
}
return nodes
})
// Notify parent when there is only a single root category to auto-expand
watch(
categoryTree,
(nodes) => {
if (nodes.length === 1 && nodes[0].children?.length) {
const rootKey = nodes[0].key
if (
selectedCategory.value !== rootKey &&
!selectedCategory.value.startsWith(rootKey + '/')
) {
emit('autoExpand', rootKey)
}
}
},
{ immediate: true }
)
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
'h-auto justify-start bg-transparent py-2.5 pr-3 text-sm font-normal',
hideChevrons ? 'pl-3' : 'pl-9',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
}
const selectedCollapsed = ref(false)
const expandedCategory = ref(selectedCategory.value)
// Skips the watch when selectCategory/collapseCategory set selectedCategory,
// so their expandedCategory toggle isn't immediately undone.
let lastEmittedCategory = ''
watch(selectedCategory, (val) => {
if (val !== lastEmittedCategory) {
expandedCategory.value = val
}
lastEmittedCategory = ''
})
function parentCategory(key: string): string {
const i = key.lastIndexOf('/')
return i > 0 ? key.slice(0, i) : ''
}
function selectCategory(categoryId: string) {
if (selectedCategory.value === categoryId) {
selectedCollapsed.value = !selectedCollapsed.value
if (expandedCategory.value === categoryId) {
expandedCategory.value = parentCategory(categoryId)
} else {
selectedCollapsed.value = false
selectedCategory.value = categoryId
expandedCategory.value = categoryId
}
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
function collapseCategory(categoryId: string) {
expandedCategory.value = parentCategory(categoryId)
lastEmittedCategory = categoryId
selectedCategory.value = categoryId
}
</script>

View File

@@ -1,32 +1,66 @@
<template>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
<div
:class="
cn(
'w-full cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
selectedCategory === node.key &&
isExpanded &&
node.children?.length &&
'rounded-lg bg-secondary-background'
)
"
@click="$emit('select', node.key)"
>
{{ node.label }}
</button>
<template v-if="isExpanded && node.children?.length">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
@select="$emit('select', $event)"
/>
</template>
<RovingFocusItem as-child>
<Button
ref="buttonEl"
type="button"
role="treeitem"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:aria-expanded="node.children?.length ? isExpanded : undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'h-auto w-full gap-2 bg-transparent py-2.5 pr-3 text-left text-sm font-normal',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
@keydown.right.prevent="handleRight"
@keydown.left.prevent="handleLeft"
>
<i
v-if="!hideChevrons"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</Button>
</RovingFocusItem>
<div v-if="isExpanded && node.children?.length" role="group">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
ref="childRefs"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:expanded-category="expandedCategory"
:hide-chevrons="hideChevrons"
:focus-parent="focusSelf"
@select="$emit('select', $event)"
@collapse="$emit('collapse', $event)"
/>
</div>
</div>
</template>
<script lang="ts">
@@ -37,34 +71,75 @@ export interface CategoryNode {
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover font-semibold text-foreground'
'bg-secondary-background-hover text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { RovingFocusItem } from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
node,
depth = 0,
selectedCategory,
selectedCollapsed = false
expandedCategory,
hideChevrons = false,
focusParent
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
expandedCategory: string
hideChevrons?: boolean
focusParent?: () => void
}>()
defineEmits<{
const emit = defineEmits<{
select: [key: string]
collapse: [key: string]
}>()
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
const buttonEl = ref<InstanceType<typeof Button>>()
const childRefs = ref<{ focus?: () => void }[]>([])
function focusSelf() {
const el = buttonEl.value?.$el as HTMLElement | undefined
el?.focus()
}
defineExpose({ focus: focusSelf })
const isExpanded = computed(
() =>
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
)
function handleRight() {
if (!node.children?.length) return
if (!isExpanded.value) {
emit('select', node.key)
return
}
nextTick(() => {
childRefs.value[0]?.focus?.()
})
}
function handleLeft() {
if (node.children?.length && isExpanded.value) {
if (expandedCategory.startsWith(node.key + '/')) {
emit('collapse', node.key)
} else {
emit('select', node.key)
}
return
}
focusParent?.()
}
</script>

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/vue'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
@@ -9,33 +8,28 @@ import {
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
})
async function renderComponent(props = {}) {
function renderComponent(props = {}) {
const user = userEvent.setup()
const onAddNode = vi.fn()
const onHoverNode = vi.fn()
const onRemoveFilter = vi.fn()
const onRemoveFilter =
vi.fn<(f: FuseFilterWithValue<ComfyNodeDefImpl, string>) => void>()
const onAddFilter = vi.fn()
render(NodeSearchContent, {
props: {
@@ -63,18 +57,40 @@ describe('NodeSearchContent', () => {
}
}
})
await nextTick()
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
}
function mockBookmarks(
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
bookmarkList: string[] = []
) {
const bookmarkStore = useNodeBookmarkStore()
if (typeof isBookmarked === 'function') {
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
} else {
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
}
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
}
function clickFilterBarButton(
user: ReturnType<typeof userEvent.setup>,
text: string
) {
const btn = screen
.getAllByRole('button')
.find((b) => b.textContent?.trim() === text)
expect(btn, `Expected filter button "${text}"`).toBeDefined()
return user.click(btn!)
}
async function setupFavorites(
nodes: Parameters<typeof createMockNodeDef>[0][]
) {
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
const result = await renderComponent()
await result.user.click(screen.getByTestId('category-favorites'))
await nextTick()
mockBookmarks(true, ['placeholder'])
const result = renderComponent()
await clickFilterBarButton(result.user, 'Bookmarked')
return result
}
@@ -92,11 +108,13 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['FrequentNode']
])
await renderComponent()
renderComponent()
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only bookmarked nodes when Favorites is selected', async () => {
@@ -110,30 +128,31 @@ describe('NodeSearchContent', () => {
display_name: 'Regular Node'
})
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
mockBookmarks(
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
['BookmarkedNode']
)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const { user } = renderComponent()
await clickFilterBarButton(user, 'Bookmarked')
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Bookmarked')
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Bookmarked')
})
})
it('should show empty state when no bookmarks exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
])
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
mockBookmarks(false, ['placeholder'])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
const { user } = renderComponent()
await clickFilterBarButton(user, 'Bookmarked')
expect(screen.getByText('No results')).toBeInTheDocument()
expect(await screen.findByText('No Results')).toBeInTheDocument()
})
it('should show only CustomNodes when Extensions is selected', async () => {
@@ -149,7 +168,6 @@ describe('NodeSearchContent', () => {
python_module: 'custom_nodes.my_extension'
})
])
await nextTick()
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
NodeSourceType.Core
@@ -158,16 +176,17 @@ describe('NodeSearchContent', () => {
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
).toBe(NodeSourceType.CustomNodes)
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-extensions'))
await nextTick()
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Custom Node')
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Custom Node')
})
})
it('should hide Essentials category when no essential nodes exist', async () => {
it('should hide Essentials filter button when no essential nodes exist', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'RegularNode',
@@ -175,10 +194,11 @@ describe('NodeSearchContent', () => {
})
])
await renderComponent()
expect(
screen.queryByTestId('category-essentials')
).not.toBeInTheDocument()
renderComponent()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).not.toContain('Essentials')
})
it('should show only essential nodes when Essentials is selected', async () => {
@@ -193,15 +213,70 @@ describe('NodeSearchContent', () => {
display_name: 'Regular Node'
})
])
await nextTick()
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-essentials'))
await nextTick()
const { user } = renderComponent()
await clickFilterBarButton(user, 'Essentials')
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ApiNode',
display_name: 'API Node',
api_node: true
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
const { user } = renderComponent()
await clickFilterBarButton(user, 'Partner')
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('API Node')
})
})
it('should toggle filter off when clicking the active filter button again', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
display_name: 'Core Node',
python_module: 'nodes'
}),
createMockNodeDef({
name: 'CustomNode',
display_name: 'Custom Node',
python_module: 'custom_nodes.my_extension'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['CoreNode'],
useNodeDefStore().nodeDefsByName['CustomNode']
])
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
await waitFor(() => {
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
})
await clickFilterBarButton(user, 'Extensions')
await waitFor(() => {
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
})
})
it('should include subcategory nodes when parent category is selected', async () => {
@@ -223,19 +298,20 @@ describe('NodeSearchContent', () => {
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
const { user } = renderComponent()
await user.click(await screen.findByTestId('category-sampling'))
await waitFor(() => {
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
})
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts).toHaveLength(2)
expect(texts).toContain('KSampler')
expect(texts).toContain('KSampler Advanced')
})
})
describe('search and category interaction', () => {
it('should override category to most-relevant when search query is active', async () => {
it('should search within selected category', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'KSampler',
@@ -249,35 +325,38 @@ describe('NodeSearchContent', () => {
})
])
const { user } = await renderComponent()
await user.click(screen.getByTestId('category-sampling'))
await nextTick()
const { user } = renderComponent()
await user.click(await screen.findByTestId('category-sampling'))
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
await waitFor(() => {
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
})
const input = screen.getByRole('combobox')
await user.type(input, 'Load')
await nextTick()
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
await waitFor(() => {
const texts = screen
.queryAllByTestId('node-item')
.map((i) => i.textContent)
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(false)
})
})
it('should clear search query when category changes', async () => {
it('should preserve search query when category changes', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
])
mockBookmarks(true, ['placeholder'])
const { user } = await renderComponent()
const { user } = renderComponent()
const input = screen.getByRole('combobox')
await user.type(input, 'test query')
await nextTick()
expect(input).toHaveValue('test query')
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
expect(input).toHaveValue('')
await clickFilterBarButton(user, 'Bookmarked')
expect(input).toHaveValue('test query')
})
it('should reset selected index when search query changes', async () => {
@@ -289,18 +368,20 @@ describe('NodeSearchContent', () => {
const input = screen.getByRole('combobox')
await user.click(input)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await waitFor(() => {
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
'true'
)
})
await user.type(input, 'Node')
await nextTick()
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
await waitFor(() => {
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
})
it('should reset selected index when category changes', async () => {
@@ -311,17 +392,16 @@ describe('NodeSearchContent', () => {
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}')
await nextTick()
await user.click(screen.getByTestId('category-most-relevant'))
await nextTick()
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
await clickFilterBarButton(user, 'Bookmarked')
await clickFilterBarButton(user, 'Bookmarked')
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
await waitFor(() => {
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
})
})
@@ -342,24 +422,19 @@ describe('NodeSearchContent', () => {
expect(selectedIndex()).toBe(0)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(1)
await waitFor(() => expect(selectedIndex()).toBe(1))
await user.keyboard('{ArrowDown}')
await nextTick()
expect(selectedIndex()).toBe(2)
await waitFor(() => expect(selectedIndex()).toBe(2))
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(1)
await waitFor(() => expect(selectedIndex()).toBe(1))
// Navigate to first, then try going above — should clamp
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
await waitFor(() => expect(selectedIndex()).toBe(0))
await user.keyboard('{ArrowUp}')
await nextTick()
expect(selectedIndex()).toBe(0)
})
@@ -370,7 +445,6 @@ describe('NodeSearchContent', () => {
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' })
@@ -385,9 +459,10 @@ describe('NodeSearchContent', () => {
const results = screen.getAllByTestId('result-item')
await user.hover(results[1])
await nextTick()
expect(results[1]).toHaveAttribute('aria-selected', 'true')
await waitFor(() => {
expect(results[1]).toHaveAttribute('aria-selected', 'true')
})
})
it('should add node on click', async () => {
@@ -396,13 +471,54 @@ describe('NodeSearchContent', () => {
])
await user.click(screen.getAllByTestId('result-item')[0])
await nextTick()
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' }),
expect.any(PointerEvent)
)
})
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
const { user } = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const results = screen.getAllByTestId('result-item')
results[0].focus()
await user.keyboard('{ArrowDown}')
await waitFor(() => {
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
'aria-selected',
'true'
)
})
screen.getAllByTestId('result-item')[1].focus()
await user.keyboard('{ArrowDown}')
await waitFor(() => {
expect(screen.getAllByTestId('result-item')[2]).toHaveAttribute(
'aria-selected',
'true'
)
})
})
it('should select node with Enter from a focused result item', async () => {
const { user, onAddNode } = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
screen.getAllByTestId('result-item')[0].focus()
await user.keyboard('{Enter}')
expect(onAddNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'TestNode' })
)
})
})
describe('hoverNode emission', () => {
@@ -411,26 +527,27 @@ describe('NodeSearchContent', () => {
{ name: 'HoverNode', display_name: 'Hover Node' }
])
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toMatchObject({
name: 'HoverNode'
await waitFor(() => {
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toMatchObject({ name: 'HoverNode' })
})
})
it('should emit null hoverNode when no results', async () => {
const { user, onHoverNode } = await renderComponent()
mockBookmarks(false, ['placeholder'])
const { user, onHoverNode } = renderComponent()
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
await user.click(screen.getByTestId('category-favorites'))
await nextTick()
await clickFilterBarButton(user, 'Bookmarked')
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toBeNull()
await waitFor(() => {
const calls = onHoverNode.mock.calls
expect(calls[calls.length - 1][0]).toBeNull()
})
})
})
describe('filter integration', () => {
it('should display active filters in the input area', async () => {
it('should display active filters in the input area', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
@@ -439,7 +556,7 @@ describe('NodeSearchContent', () => {
})
])
await renderComponent({
renderComponent({
filters: [
{
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
@@ -474,13 +591,11 @@ describe('NodeSearchContent', () => {
it('should emit removeFilter on backspace', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
const { user, onRemoveFilter } = renderComponent({ filters })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
await user.keyboard('{Backspace}')
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
@@ -489,247 +604,102 @@ describe('NodeSearchContent', () => {
})
it('should not interact with chips when no filters exist', async () => {
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
const { user, onRemoveFilter } = renderComponent({ filters: [] })
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Backspace}')
await nextTick()
expect(onRemoveFilter).not.toHaveBeenCalled()
})
it('should remove chip when clicking its delete button', async () => {
const filters = createFilters(1)
const { user, onRemoveFilter } = await renderComponent({ filters })
const { user, onRemoveFilter } = renderComponent({ filters })
await user.click(screen.getByTestId('chip-delete'))
await nextTick()
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
expect(onRemoveFilter).toHaveBeenCalledWith(
expect.objectContaining({ value: 'IMAGE' })
)
})
})
describe('filter selection mode', () => {
function setupNodesWithTypes() {
it('should emit removeFilter for every filter in a group when cleared', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'ImageNode',
display_name: 'Image Node',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE']
input: { required: { image: ['IMAGE', {}] } }
}),
createMockNodeDef({
name: 'LatentNode',
display_name: 'Latent Node',
input: { required: { latent: ['LATENT', {}] } },
output: ['LATENT']
}),
createMockNodeDef({
name: 'ModelNode',
display_name: 'Model Node',
input: { required: { model: ['MODEL', {}] } },
output: ['MODEL']
input: { required: { latent: ['LATENT', {}] } }
})
])
}
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
const filters = [
{ filterDef: inputFilter, value: 'IMAGE' },
{ filterDef: inputFilter, value: 'LATENT' }
]
function findFilterBarButton(label: string) {
return screen.getAllByRole('button').find((b) => b.textContent === label)
}
const { user, onRemoveFilter } = renderComponent({ filters })
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
const btn = findFilterBarButton('Input')
expect(btn).toBeDefined()
await user.click(btn!)
await nextTick()
}
const inputBtn = screen.getByRole('button', { name: /Input/ })
await user.click(inputBtn)
function hasSidebar() {
return screen.queryByTestId('category-most-relevant') !== null
}
const clearBtn = await screen.findByRole('button', { name: 'Clear all' })
await user.click(clearBtn)
it('should enter filter mode when a filter chip is selected', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
expect(hasSidebar()).toBe(true)
await enterFilterMode(user)
expect(hasSidebar()).toBe(false)
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
await waitFor(() => {
expect(onRemoveFilter).toHaveBeenCalledTimes(2)
})
const removedValues = onRemoveFilter.mock.calls.map(([f]) => f.value)
expect(removedValues).toEqual(expect.arrayContaining(['IMAGE', 'LATENT']))
})
})
it('should show available filter options sorted alphabetically', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).toContain('LATENT')
expect(texts).toContain('MODEL')
expect(texts).toEqual([...texts].sort())
})
it('should filter options when typing in filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.type(screen.getByRole('combobox'), 'IMAGE')
await nextTick()
const texts = screen.getAllByTestId('filter-option').map(
(o) =>
/* eslint-disable testing-library/no-node-access */
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
?.replace(/^[•·]\s*/, '')
.trim() ?? ''
/* eslint-enable testing-library/no-node-access */
)
expect(texts).toContain('IMAGE')
expect(texts).not.toContain('MODEL')
})
it('should show no results when filter query has no matches', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
await nextTick()
expect(screen.getByText('No results')).toBeInTheDocument()
})
it('should emit addFilter when a filter option is clicked', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
const imageOption = screen
.getAllByTestId('filter-option')
.find((o) => o.textContent?.includes('IMAGE'))
await user.click(imageOption!)
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
describe('rootFilter + category + search combination', () => {
it('should intersect rootFilter, selected category, and search query', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CustomSampler',
display_name: 'Custom Sampler',
category: 'sampling',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CustomLoader',
display_name: 'Custom Loader',
category: 'loaders',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CoreSampler',
display_name: 'Core Sampler',
category: 'sampling',
python_module: 'nodes'
})
)
})
])
it('should exit filter mode after applying a filter', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
const { user } = renderComponent()
await user.click(screen.getAllByTestId('filter-option')[0])
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
})
it('should emit addFilter when Enter is pressed on selected option', async () => {
setupNodesWithTypes()
const { user, onAddFilter } = await renderComponent()
await enterFilterMode(user)
await user.click(screen.getByRole('combobox'))
await user.keyboard('{Enter}')
await nextTick()
expect(onAddFilter).toHaveBeenCalledWith(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'input' }),
value: 'IMAGE'
})
)
})
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.click(screen.getByRole('combobox'))
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
await user.keyboard('{ArrowDown}')
await nextTick()
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
'aria-selected',
'true'
)
await user.keyboard('{ArrowUp}')
await nextTick()
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
'aria-selected',
'true'
)
})
it('should toggle filter mode off when same chip is clicked again', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
})
it('should reset filter query when re-entering filter mode', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
await clickFilterBarButton(user, 'Extensions')
const samplingBtn = await screen.findByTestId('category-custom/sampling')
await user.click(samplingBtn)
const input = screen.getByRole('combobox')
await user.type(input, 'IMAGE')
await nextTick()
await user.type(input, 'Custom')
await user.click(findFilterBarButton('Input')!)
await nextTick()
await nextTick()
await enterFilterMode(user)
expect(input).toHaveValue('')
})
it('should exit filter mode when cancel button is clicked', async () => {
setupNodesWithTypes()
const { user } = await renderComponent()
await enterFilterMode(user)
expect(hasSidebar()).toBe(false)
await user.click(screen.getByTestId('cancel-filter'))
await nextTick()
await nextTick()
expect(hasSidebar()).toBe(true)
await waitFor(() => {
expect(screen.queryAllByTestId('node-item')).toHaveLength(1)
})
const texts = screen
.queryAllByTestId('node-item')
.map((i) => i.textContent)
expect(texts).toContain('Custom Sampler')
expect(texts).not.toContain('Core Sampler')
expect(texts).not.toContain('Custom Loader')
})
})
})

View File

@@ -1,107 +1,130 @@
<template>
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
v-model:filter-query="filterQuery"
:filters="filters"
:active-filter="activeFilter"
@remove-filter="emit('removeFilter', $event)"
@cancel-filter="cancelFilter"
@navigate-down="onKeyDown"
@navigate-up="onKeyUp"
@select-current="onKeyEnter"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@select-chip="onSelectFilterChip"
/>
</div>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar (hidden in filter mode) -->
<NodeSearchCategorySidebar
v-if="!activeFilter"
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
<FocusScope as-child loop>
<div
ref="dialogRef"
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
ref="searchInputRef"
v-model:search-query="searchQuery"
:filters="filters"
@remove-filter="emit('removeFilter', $event)"
@navigate-down="navigateResults(1)"
@navigate-up="navigateResults(-1)"
@select-current="selectCurrentResult"
/>
<!-- Filter options list (filter selection mode) -->
<NodeSearchFilterPanel
v-if="activeFilter"
ref="filterPanelRef"
v-model:query="filterQuery"
:chip="activeFilter"
@apply="onFilterApply"
/>
<!-- Filter header row -->
<div class="flex items-center">
<NodeSearchFilterBar
class="flex-1"
:filters="filters"
:active-category="rootFilter"
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
:has-essential-nodes="nodeAvailability.essential"
:has-blueprint-nodes="nodeAvailability.blueprint"
:has-partner-nodes="nodeAvailability.partner"
:has-custom-nodes="nodeAvailability.custom"
@toggle-filter="onToggleFilter"
@clear-filter-group="onClearFilterGroup"
@focus-search="nextTick(() => searchInputRef?.focus())"
@select-category="onSelectCategory"
/>
</div>
<!-- Results list (normal mode) -->
<div
v-else
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<!-- Content area -->
<div class="flex min-h-0 flex-1 overflow-hidden">
<!-- Category sidebar -->
<NodeSearchCategorySidebar
v-model:selected-category="sidebarCategory"
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:node-defs="rootFilteredNodeDefs"
:root-label="rootFilterLabel"
:root-key="rootFilter ?? undefined"
@auto-expand="selectedCategory = $event"
/>
<!-- Results list -->
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center px-4',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('addNode', node, $event)"
@mouseenter="selectedIndex = index"
id="results-list"
role="listbox"
tabindex="-1"
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
@pointermove="onPointerMove"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="effectiveCategory !== 'essentials'"
:hide-bookmark-icon="effectiveCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
<div
v-for="(node, index) in displayedResults"
:id="`result-item-${index}`"
:key="node.name"
role="option"
data-testid="result-item"
:tabindex="index === selectedIndex ? 0 : -1"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
index === selectedIndex && 'bg-secondary-background'
)
"
@click="emit('addNode', node, $event)"
@keydown.down.prevent="navigateResults(1, true)"
@keydown.up.prevent="navigateResults(-1, true)"
@keydown.enter.prevent="selectCurrentResult"
>
<NodeSearchListItem
:node-def="node"
:current-query="searchQuery"
show-description
:show-source-badge="rootFilter !== 'essentials'"
:hide-bookmark-icon="selectedCategory === 'favorites'"
/>
</div>
<div
v-if="displayedResults.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</div>
</div>
</div>
</FocusScope>
</template>
<script setup lang="ts">
import { FocusScope } from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
isEssentialNode,
NodeSourceType
} from '@/types/nodeSource'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import { cn } from '@comfyorg/tailwind-utils'
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
{
essentials: isEssentialNode,
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
custom: isCustomNode
}
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
}>()
@@ -113,57 +136,102 @@ const emit = defineEmits<{
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeAvailability = computed(() => {
let essential = false
let blueprint = false
let partner = false
let custom = false
for (const n of nodeDefStore.visibleNodeDefs) {
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
essential = true
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
blueprint = true
if (!partner && n.api_node) partner = true
if (!custom && isCustomNode(n)) custom = true
if (essential && blueprint && partner && custom) break
}
return { essential, blueprint, partner, custom }
})
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<string | null>(null)
function lockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {
case 'favorites':
return t('g.bookmarked')
case BLUEPRINT_CATEGORY:
return t('g.blueprints')
case 'partner-nodes':
return t('g.partner')
case 'essentials':
return t('g.essentials')
case 'comfy':
return t('g.comfy')
case 'custom':
return t('g.extensions')
default:
return undefined
}
})
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case 'favorites':
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case BLUEPRINT_CATEGORY:
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
case 'partner-nodes':
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
})
function onToggleFilter(
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
value: string
) {
const existing = filters.find(
(f) => f.filterDef.id === filterDef.id && f.value === value
)
if (existing) {
emit('removeFilter', existing)
} else {
emit('addFilter', { filterDef, value })
}
}
function unlockDialogHeight() {
if (dialogRef.value) {
dialogRef.value.style.height = ''
function onClearFilterGroup(filterId: string) {
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
emit('removeFilter', f)
}
}
function onSelectFilterChip(chip: FilterChip) {
if (activeFilter.value?.key === chip.key) {
cancelFilter()
return
function onSelectCategory(category: string) {
if (rootFilter.value === category) {
rootFilter.value = null
} else {
rootFilter.value = category
}
lockDialogHeight()
activeFilter.value = chip
filterQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
function onFilterApply(value: string) {
if (!activeFilter.value) return
emit('addFilter', { filterDef: activeFilter.value.filter, value })
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
nextTick(() => searchInputRef.value?.focus())
}
function cancelFilter() {
activeFilter.value = null
filterQuery.value = ''
unlockDialogHeight()
selectedCategory.value = DEFAULT_CATEGORY
nextTick(() => searchInputRef.value?.focus())
}
@@ -176,67 +244,68 @@ const searchResults = computed(() => {
})
})
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
)
const sidebarCategory = computed({
get: () => effectiveCategory.value,
get: () => selectedCategory.value,
set: (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
}
})
function matchesFilters(node: ComfyNodeDefImpl): boolean {
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
// Check if any tree category has children (for chevron visibility)
const anyTreeCategoryHasChildren = computed(() =>
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
)
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
if (searchQuery.value || filters.length > 0) {
const searched = searchResults.value
if (!rootFilter.value) return searched
const rootSet = new Set(baseNodes.map((n) => n.name))
return searched.filter((n) => rootSet.has(n.name))
}
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
}
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
if (rootFilter.value && category === rootFilter.value) return baseNodes
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
const categoryPath = category.startsWith(rootPrefix)
? category.slice(rootPrefix.length)
: category
return baseNodes.filter((n) => {
const nodeCategory = n.category.startsWith(rootPrefix)
? n.category.slice(rootPrefix.length)
: n.category
return (
nodeCategory === categoryPath ||
nodeCategory.startsWith(categoryPath + '/')
)
})
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const allNodes = nodeDefStore.visibleNodeDefs
const baseNodes = rootFilteredNodeDefs.value
const category = selectedCategory.value
let results: ComfyNodeDefImpl[]
switch (effectiveCategory.value) {
case 'most-relevant':
return searchResults.value
case 'favorites':
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
break
case 'essentials':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
results = allNodes.filter(
(n) =>
n.category === effectiveCategory.value ||
n.category.startsWith(effectiveCategory.value + '/')
)
break
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
const hasSearch = searchQuery.value || filters.length > 0
let source: ComfyNodeDefImpl[]
if (hasSearch) {
const searched = searchResults.value
if (rootFilter.value) {
const rootSet = new Set(baseNodes.map((n) => n.name))
source = searched.filter((n) => rootSet.has(n.name))
} else {
source = searched
}
} else {
source = baseNodes
}
return filters.length > 0 ? results.filter(matchesFilters) : results
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
})
const hoveredNodeDef = computed(
@@ -251,42 +320,28 @@ watch(
{ immediate: true }
)
watch([selectedCategory, searchQuery, () => filters], () => {
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
selectedIndex.value = 0
})
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)
} else {
navigateResults(1)
}
function onPointerMove(event: PointerEvent) {
const item = (event.target as HTMLElement).closest('[role=option]')
if (!item) return
const index = Number(item.id.replace('result-item-', ''))
if (!isNaN(index) && index !== selectedIndex.value)
selectedIndex.value = index
}
function onKeyUp() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(-1)
} else {
navigateResults(-1)
}
}
function onKeyEnter() {
if (activeFilter.value) {
filterPanelRef.value?.selectCurrent()
} else {
selectCurrentResult()
}
}
function navigateResults(direction: number) {
function navigateResults(direction: number, focusItem = false) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
dialogRef.value
?.querySelector(`#result-item-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
const el = dialogRef.value?.querySelector(
`#result-item-${newIndex}`
) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
if (focusItem) el?.focus()
})
}
}

View File

@@ -1,4 +1,4 @@
import { render, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -13,7 +13,11 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
@@ -33,57 +37,81 @@ describe(NodeSearchFilterBar, () => {
async function createRender(props = {}) {
const user = userEvent.setup()
const onSelectChip = vi.fn()
const { container } = render(NodeSearchFilterBar, {
props: { onSelectChip, ...props },
global: { plugins: [testI18n] }
const onSelectCategory = vi.fn()
render(NodeSearchFilterBar, {
props: { onSelectCategory, ...props },
global: {
plugins: [testI18n],
stubs: {
NodeSearchTypeFilterPopover: {
template: '<div data-testid="popover"><slot /></div>',
props: ['chip', 'selectedValues']
}
}
}
})
await nextTick()
const view = within(container as HTMLElement)
return { user, onSelectChip, view }
return { user, onSelectCategory }
}
it('should render all filter chips', async () => {
const { view } = await createRender()
it('should render Extensions button and Input/Output popover triggers', async () => {
await createRender({ hasCustomNodes: true })
const buttons = view.getAllByRole('button')
expect(buttons).toHaveLength(6)
expect(buttons[0]).toHaveTextContent('Blueprints')
expect(buttons[1]).toHaveTextContent('Partner Nodes')
expect(buttons[2]).toHaveTextContent('Essentials')
expect(buttons[3]).toHaveTextContent('Extensions')
expect(buttons[4]).toHaveTextContent('Input')
expect(buttons[5]).toHaveTextContent('Output')
const buttons = screen.getAllByRole('button')
const texts = buttons.map((b) => b.textContent?.trim())
expect(texts).toContain('Extensions')
expect(texts).toContain('Input')
expect(texts).toContain('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {
const { view } = await createRender({ activeChipKey: 'input' })
it('should always render Comfy button', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Comfy')
})
expect(view.getByRole('button', { name: 'Input' })).toHaveAttribute(
it('should render conditional category buttons when matching nodes exist', async () => {
await createRender({
hasFavorites: true,
hasEssentialNodes: true,
hasBlueprintNodes: true,
hasPartnerNodes: true
})
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).toContain('Bookmarked')
expect(texts).toContain('Blueprints')
expect(texts).toContain('Partner')
expect(texts).toContain('Essentials')
})
it('should not render Extensions button when no custom nodes exist', async () => {
await createRender()
const texts = screen
.getAllByRole('button')
.map((b) => b.textContent?.trim())
expect(texts).not.toContain('Extensions')
})
it('should emit selectCategory when category button is clicked', async () => {
const { user, onSelectCategory } = await createRender({
hasCustomNodes: true
})
await user.click(screen.getByRole('button', { name: 'Extensions' }))
expect(onSelectCategory).toHaveBeenCalledWith('custom')
})
it('should apply active styling when activeCategory matches', async () => {
await createRender({ activeCategory: 'custom', hasCustomNodes: true })
expect(screen.getByRole('button', { name: 'Extensions' })).toHaveAttribute(
'aria-pressed',
'true'
)
})
it('should not mark chips as pressed when activeChipKey does not match', async () => {
const { view } = await createRender({ activeChipKey: null })
view.getAllByRole('button').forEach((btn) => {
expect(btn).toHaveAttribute('aria-pressed', 'false')
})
})
it('should emit selectChip with chip data when clicked', async () => {
const { user, onSelectChip, view } = await createRender()
await user.click(view.getByRole('button', { name: 'Input' }))
expect(onSelectChip).toHaveBeenCalledWith(
expect.objectContaining({
key: 'input',
label: 'Input',
filter: expect.anything()
})
)
})
})

View File

@@ -1,22 +1,43 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2.5 px-3">
<!-- Category filter buttons -->
<button
v-for="chip in chips"
:key="chip.key"
v-for="btn in categoryButtons"
:key="btn.id"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
activeChipKey === chip.key
? 'text-foreground bg-secondary-background'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
:aria-pressed="activeCategory === btn.id"
:class="chipClass(activeCategory === btn.id)"
@click="emit('selectCategory', btn.id)"
>
{{ chip.label }}
{{ btn.label }}
</button>
<div class="h-5 w-px shrink-0 bg-border-subtle" />
<!-- Type filter popovers (Input / Output) -->
<NodeSearchTypeFilterPopover
v-for="tf in typeFilters"
:key="tf.chip.key"
:chip="tf.chip"
:selected-values="tf.values"
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
@escape-close="emit('focusSearch')"
>
<button type="button" :class="chipClass(false, tf.values.length > 0)">
<span v-if="tf.values.length > 0" class="flex items-center">
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>
</span>
{{ tf.chip.label }}
<i class="icon-[lucide--chevron-down] size-3.5" />
</button>
</NodeSearchTypeFilterPopover>
</div>
</template>
@@ -35,53 +56,97 @@ export interface FilterChip {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { BLUEPRINT_CATEGORY } from '@/types/nodeSource'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { activeChipKey = null } = defineProps<{
activeChipKey?: string | null
const {
filters = [],
activeCategory = null,
hasFavorites = false,
hasEssentialNodes = false,
hasBlueprintNodes = false,
hasPartnerNodes = false,
hasCustomNodes = false
} = defineProps<{
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeCategory?: string | null
hasFavorites?: boolean
hasEssentialNodes?: boolean
hasBlueprintNodes?: boolean
hasPartnerNodes?: boolean
hasCustomNodes?: boolean
}>()
const emit = defineEmits<{
selectChip: [chip: FilterChip]
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
clearFilterGroup: [filterId: string]
focusSearch: []
selectCategory: [category: string]
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
filter: searchService.inputTypeFilter
},
{
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
}
]
const MAX_VISIBLE_DOTS = 4
const categoryButtons = computed(() => {
const buttons: { id: string; label: string }[] = []
if (hasFavorites) {
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
}
if (hasBlueprintNodes) {
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
}
if (hasPartnerNodes) {
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
}
if (hasEssentialNodes) {
buttons.push({ id: 'essentials', label: t('g.essentials') })
}
buttons.push({ id: 'comfy', label: t('g.comfy') })
if (hasCustomNodes) {
buttons.push({ id: 'custom', label: t('g.extensions') })
}
return buttons
})
const inputChip = computed<FilterChip>(() => ({
key: 'input',
label: t('g.input'),
filter: nodeDefStore.nodeSearchService.inputTypeFilter
}))
const outputChip = computed<FilterChip>(() => ({
key: 'output',
label: t('g.output'),
filter: nodeDefStore.nodeSearchService.outputTypeFilter
}))
const selectedInputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
)
const selectedOutputValues = computed(() =>
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
)
const typeFilters = computed(() => [
{ chip: inputChip.value, values: selectedInputValues.value },
{ chip: outputChip.value, values: selectedOutputValues.value }
])
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections
? 'border-base-foreground/60 bg-transparent text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
}
</script>

View File

@@ -1,90 +0,0 @@
<template>
<div
id="filter-options-list"
ref="listRef"
role="listbox"
class="flex-1 overflow-y-auto py-2"
>
<div
v-for="(option, index) in options"
:id="`filter-option-${index}`"
:key="option"
role="option"
data-testid="filter-option"
:aria-selected="index === selectedIndex"
:class="
cn(
'cursor-pointer px-6 py-1.5',
index === selectedIndex && 'bg-secondary-background-hover'
)
"
@click="emit('apply', option)"
@mouseenter="selectedIndex = index"
>
<span class="text-foreground text-base font-semibold">
<span class="mr-1 text-2xl" :style="{ color: getLinkTypeColor(option) }"
>&bull;</span
>
{{ option }}
</span>
</div>
<div
v-if="options.length === 0"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { chip } = defineProps<{
chip: FilterChip
}>()
const query = defineModel<string>('query', { required: true })
const emit = defineEmits<{
apply: [value: string]
}>()
const listRef = ref<HTMLElement>()
const selectedIndex = ref(0)
const options = computed(() => {
const { fuseSearch } = chip.filter
if (query.value) {
return fuseSearch.search(query.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
watch(query, () => {
selectedIndex.value = 0
})
function navigate(direction: number) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < options.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
listRef.value
?.querySelector(`#filter-option-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
})
}
}
function selectCurrent() {
const option = options.value[selectedIndex.value]
if (option) emit('apply', option)
}
defineExpose({ navigate, selectCurrent })
</script>

View File

@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import {
setupTestPinia,
@@ -19,7 +18,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(),
get: vi.fn((key: string) => {
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
return undefined
}),
set: vi.fn()
}))
}))
@@ -40,20 +43,6 @@ function createFilter(
}
}
function createActiveFilter(label: string): FilterChip {
return {
key: label.toLowerCase(),
label,
filter: {
id: label.toLowerCase(),
matches: vi.fn(() => true)
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
ComfyNodeDefImpl,
string
>
}
}
describe('NodeSearchInput', () => {
beforeEach(() => {
setupTestPinia()
@@ -63,27 +52,19 @@ describe('NodeSearchInput', () => {
function createRender(
props: Partial<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
searchQuery: string
filterQuery: string
}> = {}
) {
const user = userEvent.setup()
const onUpdateSearchQuery = vi.fn()
const onUpdateFilterQuery = vi.fn()
const onCancelFilter = vi.fn()
const onSelectCurrent = vi.fn()
const onNavigateDown = vi.fn()
const onNavigateUp = vi.fn()
render(NodeSearchInput, {
props: {
filters: [],
activeFilter: null,
searchQuery: '',
filterQuery: '',
'onUpdate:searchQuery': onUpdateSearchQuery,
'onUpdate:filterQuery': onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp,
@@ -94,43 +75,20 @@ describe('NodeSearchInput', () => {
return {
user,
onUpdateSearchQuery,
onUpdateFilterQuery,
onCancelFilter,
onSelectCurrent,
onNavigateDown,
onNavigateUp
}
}
it('should route input to searchQuery when no active filter', async () => {
it('should route input to searchQuery', async () => {
const { user, onUpdateSearchQuery } = createRender()
await user.type(screen.getByRole('combobox'), 'test search')
expect(onUpdateSearchQuery).toHaveBeenLastCalledWith('test search')
})
it('should route input to filterQuery when active filter is set', async () => {
const { user, onUpdateFilterQuery, onUpdateSearchQuery } = createRender({
activeFilter: createActiveFilter('Input')
})
await user.type(screen.getByRole('combobox'), 'IMAGE')
expect(onUpdateFilterQuery).toHaveBeenLastCalledWith('IMAGE')
expect(onUpdateSearchQuery).not.toHaveBeenCalled()
})
it('should show filter label placeholder when active filter is set', () => {
createRender({
activeFilter: createActiveFilter('Input')
})
expect(screen.getByRole('combobox')).toHaveAttribute(
'placeholder',
expect.stringContaining('input')
)
})
it('should show add node placeholder when no active filter', () => {
it('should show add node placeholder', () => {
createRender()
expect(screen.getByRole('combobox')).toHaveAttribute(
@@ -139,16 +97,7 @@ describe('NodeSearchInput', () => {
)
})
it('should hide filter chips when active filter is set', () => {
createRender({
filters: [createFilter('input', 'IMAGE')],
activeFilter: createActiveFilter('Input')
})
expect(screen.queryAllByTestId('filter-chip')).toHaveLength(0)
})
it('should show filter chips when no active filter', () => {
it('should show filter chips when filters are present', () => {
createRender({
filters: [createFilter('input', 'IMAGE')]
})
@@ -156,16 +105,6 @@ describe('NodeSearchInput', () => {
expect(screen.getAllByTestId('filter-chip')).toHaveLength(1)
})
it('should emit cancelFilter when cancel button is clicked', async () => {
const { user, onCancelFilter } = createRender({
activeFilter: createActiveFilter('Input')
})
await user.click(screen.getByTestId('cancel-filter'))
expect(onCancelFilter).toHaveBeenCalledOnce()
})
it('should emit selectCurrent on Enter', async () => {
const { user, onSelectCurrent } = createRender()

View File

@@ -7,61 +7,41 @@
@remove-tag="onRemoveTag"
@click="inputRef?.focus()"
>
<!-- Active filter label (filter selection mode) -->
<span
v-if="activeFilter"
class="text-foreground -my-1 inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 text-sm opacity-80"
>
{{ activeFilter.label }}:
<button
type="button"
data-testid="cancel-filter"
class="aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
:aria-label="$t('g.remove')"
@click="emit('cancelFilter')"
>
<i class="pi pi-times text-xs" />
</button>
</span>
<!-- Applied filter chips -->
<template v-if="!activeFilter">
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
<TagsInputItem
v-for="filter in filters"
:key="filterKey(filter)"
:value="filterKey(filter)"
data-testid="filter-chip"
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }"> &bull; </span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<span class="text-sm opacity-80">
{{ t(`g.${filter.filterDef.id}`) }}:
</span>
<span :style="{ color: getLinkTypeColor(filter.value) }">
&bull;
</span>
<span class="text-sm">{{ filter.value }}</span>
<TagsInputItemDelete
as="button"
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<i class="pi pi-times text-xs" />
</TagsInputItemDelete>
</TagsInputItem>
</template>
<i class="icon-[lucide--x] size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput as-child>
<input
ref="inputRef"
v-model="inputValue"
v-model="searchQuery"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
:aria-label="inputPlaceholder"
:placeholder="inputPlaceholder"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
aria-controls="results-list"
:aria-label="t('g.addNode')"
:placeholder="t('g.addNode')"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
@keydown.enter.prevent="emit('selectCurrent')"
@keydown.down.prevent="emit('navigateDown')"
@keydown.up.prevent="emit('navigateUp')"
@@ -81,22 +61,18 @@ import {
TagsInputRoot
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
const { filters, activeFilter } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
activeFilter: FilterChip | null
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const filterQuery = defineModel<string>('filterQuery', { required: true })
const emit = defineEmits<{
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
cancelFilter: []
navigateDown: []
navigateUp: []
selectCurrent: []
@@ -105,23 +81,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const inputRef = ref<HTMLInputElement>()
const inputValue = computed({
get: () => (activeFilter ? filterQuery.value : searchQuery.value),
set: (value: string) => {
if (activeFilter) {
filterQuery.value = value
} else {
searchQuery.value = value
}
}
})
const inputPlaceholder = computed(() =>
activeFilter
? t('g.filterByType', { type: activeFilter.label.toLowerCase() })
: t('g.addNode')
)
const tagValues = computed(() => filters.map(filterKey))
function filterKey(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {

View File

@@ -0,0 +1,292 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import {
createMockNodeDef,
setupTestPinia,
testI18n
} from '@/components/searchbox/v2/__test__/testUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
function renderItem(
props: Partial<ComponentProps<typeof NodeSearchListItem>> = {}
) {
return render(NodeSearchListItem, {
props: { nodeDef: createMockNodeDef(), currentQuery: '', ...props },
global: {
plugins: [testI18n],
stubs: {
NodePricingBadge: {
template: '<div data-testid="pricing-badge" />',
props: ['nodeDef']
},
ComfyLogo: { template: '<div data-testid="comfy-logo" />' }
}
}
})
}
describe('NodeSearchListItem', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
})
describe('id name badge', () => {
it('shows id name when ShowIdName setting is enabled', () => {
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
true
renderItem({
nodeDef: createMockNodeDef({
name: 'KSamplerNode',
display_name: 'KSampler'
})
})
expect(screen.getByText('KSamplerNode')).toBeInTheDocument()
})
it('hides id name by default', () => {
renderItem({
nodeDef: createMockNodeDef({
name: 'KSamplerNode',
display_name: 'KSampler'
})
})
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
})
})
describe('showDescription mode', () => {
it('renders description text', () => {
renderItem({
nodeDef: createMockNodeDef({ description: 'A sampler node' }),
showDescription: true
})
expect(screen.getByText('A sampler node')).toBeInTheDocument()
})
it('renders category when ShowCategory setting is enabled', () => {
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowCategory'] =
true
renderItem({
nodeDef: createMockNodeDef({ category: 'sampling/advanced' }),
showDescription: true
})
expect(screen.getByText('sampling / advanced')).toBeInTheDocument()
})
it('hides category by default', () => {
renderItem({
nodeDef: createMockNodeDef({ category: 'sampling' }),
showDescription: true
})
expect(screen.queryByText('sampling')).not.toBeInTheDocument()
})
})
describe('source badge', () => {
it('renders core comfy badge for non-custom node when showSourceBadge is true', () => {
renderItem({
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
showDescription: true,
showSourceBadge: true
})
expect(screen.getByTestId('comfy-logo')).toBeInTheDocument()
})
it('renders custom node badge for custom node when showSourceBadge is true', () => {
renderItem({
nodeDef: createMockNodeDef({
python_module: 'custom_nodes.my_extension',
display_name: 'CustomNode'
}),
showDescription: true,
showSourceBadge: true
})
expect(screen.getByText('my_extension')).toBeInTheDocument()
})
it('does not render source badge when showSourceBadge is false', () => {
renderItem({
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
showDescription: true,
showSourceBadge: false
})
expect(screen.queryByTestId('comfy-logo')).not.toBeInTheDocument()
})
it('renders essentials badge for essentials node when showSourceBadge is true', () => {
renderItem({
nodeDef: createMockNodeDef({
python_module: 'custom_nodes.my_essentials',
essentials_category: 'essentials'
}),
showDescription: true,
showSourceBadge: true
})
expect(screen.getByText('my_essentials')).toBeInTheDocument()
})
it('renders blueprint badge for blueprint node when showSourceBadge is true', () => {
renderItem({
nodeDef: createMockNodeDef({
python_module: 'blueprint.my_blueprint'
}),
showDescription: true,
showSourceBadge: true
})
expect(screen.getByText('Blueprint')).toBeInTheDocument()
})
})
describe('API node provider badge', () => {
it('renders provider badge only when nodeDef.api_node is true', () => {
renderItem({
nodeDef: createMockNodeDef({
api_node: true,
category: 'api/image/BFL'
})
})
expect(screen.getByText('BFL')).toBeInTheDocument()
})
it('does not render provider badge when nodeDef.api_node is false', () => {
renderItem({
nodeDef: createMockNodeDef({
api_node: false,
category: 'api/image/BFL'
})
})
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
})
})
describe('status flags', () => {
it('shows deprecated label when deprecated', () => {
renderItem({ nodeDef: createMockNodeDef({ deprecated: true }) })
expect(screen.getByText('DEPR')).toBeInTheDocument()
})
it('shows experimental label when experimental', () => {
renderItem({ nodeDef: createMockNodeDef({ experimental: true }) })
expect(screen.getByText('BETA')).toBeInTheDocument()
})
it('shows devOnly label when dev_only is set', () => {
renderItem({ nodeDef: createMockNodeDef({ dev_only: true }) })
expect(screen.getByText('DEV')).toBeInTheDocument()
})
it('does not show flags in description mode', () => {
renderItem({
nodeDef: createMockNodeDef({ deprecated: true, experimental: true }),
showDescription: true
})
expect(screen.queryByText('DEPR')).not.toBeInTheDocument()
expect(screen.queryByText('BETA')).not.toBeInTheDocument()
})
})
describe('node frequency badge', () => {
it('shows frequency when ShowNodeFrequency is enabled and frequency > 0', () => {
useSettingStore().settingValues[
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
] = true
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
1500
)
renderItem({ nodeDef: createMockNodeDef() })
const badge = screen.getByTestId('frequency-badge')
expect(badge).toBeInTheDocument()
expect(badge.textContent).toMatch(/1\.5k/i)
})
it('hides frequency when frequency is 0 even if setting is enabled', () => {
useSettingStore().settingValues[
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
] = true
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(0)
renderItem({ nodeDef: createMockNodeDef() })
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
})
it('hides frequency when setting is disabled even if frequency > 0', () => {
useSettingStore().settingValues[
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
] = false
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
9999
)
renderItem({ nodeDef: createMockNodeDef() })
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
})
})
describe('bookmark icon', () => {
it('shows bookmark icon when node is bookmarked', () => {
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
'TestNode'
]
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
expect(
screen.getByRole('img', { name: 'Bookmarked' })
).toBeInTheDocument()
})
it('does not show bookmark icon when node is not bookmarked', () => {
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
expect(
screen.queryByRole('img', { name: 'Bookmarked' })
).not.toBeInTheDocument()
})
it('hides bookmark icon when hideBookmarkIcon prop is true', () => {
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
'TestNode'
]
renderItem({
nodeDef: createMockNodeDef({ name: 'TestNode' }),
hideBookmarkIcon: true
})
expect(
screen.queryByRole('img', { name: 'Bookmarked' })
).not.toBeInTheDocument()
})
})
describe('query highlighting', () => {
it('wraps matching portion of display_name in a highlight span', () => {
renderItem({
nodeDef: createMockNodeDef({ display_name: 'KSampler Advanced' }),
currentQuery: 'Sampler'
})
expect(
screen.getByText('Sampler', { selector: 'span.highlight' })
).toBeInTheDocument()
})
it('does not wrap anything when currentQuery is empty', () => {
renderItem({
nodeDef: createMockNodeDef({ display_name: 'KSampler' }),
currentQuery: ''
})
expect(
screen.queryByText('KSampler', { selector: 'span.highlight' })
).not.toBeInTheDocument()
})
})
describe('node source display text', () => {
it('shows custom node source displayText in non-description mode', () => {
renderItem({
nodeDef: createMockNodeDef({
python_module: 'custom_nodes.my_extension'
})
})
expect(screen.getByText('my_extension')).toBeInTheDocument()
})
})
})

View File

@@ -2,46 +2,84 @@
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex flex-col gap-0.5 overflow-hidden">
<div class="text-foreground flex items-center gap-2 font-semibold">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<!-- Row 1: Name (left) + badges (right) -->
<div class="text-foreground flex items-center gap-2 text-sm">
<span
v-if="isBookmarked && !hideBookmarkIcon"
role="img"
:aria-label="$t('g.bookmarked')"
>
<i aria-hidden="true" class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span v-if="showIdName">&nbsp;</span>
<span
class="truncate"
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
/>
<span
v-if="showIdName"
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
<template v-if="showDescription">
<div class="flex-1" />
<div class="flex shrink-0 items-center gap-1">
<span
v-if="showSourceBadge && isCore"
aria-hidden="true"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
>
<ComfyLogo :size="10" mode="fill" color="currentColor" />
</span>
<span
v-else-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
:class="badgePillClass"
>
<span class="truncate text-2xs">
{{ nodeDef.nodeSource.displayText }}
</span>
</span>
<span
v-if="nodeDef.api_node && providerName"
:class="badgePillClass"
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
/>
<i
aria-hidden="true"
:class="cn(getProviderIcon(providerName), 'size-3')"
/>
</span>
</div>
</template>
<template v-else>
<NodePricingBadge :node-def="nodeDef" />
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
</template>
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-2xs text-muted-foreground"
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
>
<span
v-if="
showSourceBadge &&
nodeDef.nodeSource.type !== NodeSourceType.Core &&
nodeDef.nodeSource.type !== NodeSourceType.Unknown
"
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
>
{{ nodeDef.nodeSource.displayText }}
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
{{ nodeDef.category.replaceAll('/', ' / ') }}
</span>
<TextTicker v-if="nodeDef.description">
<span
v-if="nodeDef.description && showCategory"
class="h-3 w-px shrink-0 bg-border-default"
/>
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
{{ nodeDef.description }}
</TextTicker>
</div>
<div
v-else-if="showCategory"
class="option-category truncate text-sm font-light text-muted"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div v-if="!showDescription" class="flex items-center gap-1">
<span
@@ -64,6 +102,7 @@
</span>
<span
v-if="showNodeFrequency && nodeFrequency > 0"
data-testid="frequency-badge"
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{ formatNumberWithSuffix(nodeFrequency, { roundToInt: true }) }}
@@ -82,14 +121,17 @@
import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { CORE_NODE_MODULES, NodeSourceType } from '@/types/nodeSource'
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
const {
nodeDef,
@@ -105,6 +147,9 @@ const {
hideBookmarkIcon?: boolean
}>()
const badgePillClass =
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
@@ -122,4 +167,8 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
const providerName = computed(() => getProviderName(nodeDef.category))
const isCore = computed(() =>
CORE_NODE_MODULES.includes(nodeDef.python_module.split('.')[0])
)
</script>

View File

@@ -0,0 +1,176 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
function createMockChip(
data: string[] = ['IMAGE', 'LATENT', 'MODEL']
): FilterChip {
return {
key: 'input',
label: 'Input',
filter: {
id: 'input',
matches: vi.fn(),
fuseSearch: {
search: vi.fn((query: string) =>
data.filter((d) => d.toLowerCase().includes(query.toLowerCase()))
),
data
}
} as unknown as FilterChip['filter']
}
}
describe(NodeSearchTypeFilterPopover, () => {
beforeEach(() => {
vi.restoreAllMocks()
})
function createRender(
props: {
chip?: FilterChip
selectedValues?: string[]
} = {}
) {
const user = userEvent.setup()
const onToggle = vi.fn()
const onClear = vi.fn()
const onEscapeClose = vi.fn()
render(NodeSearchTypeFilterPopover, {
props: {
chip: props.chip ?? createMockChip(),
selectedValues: props.selectedValues ?? [],
onToggle,
onClear,
onEscapeClose
},
slots: {
default: '<button data-testid="trigger">Input</button>'
},
global: { plugins: [testI18n] }
})
return { user, onToggle, onClear, onEscapeClose }
}
async function openPopover(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByTestId('trigger'))
await nextTick()
await nextTick()
}
it('should render the trigger slot', () => {
createRender()
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should show popover content when trigger is clicked', async () => {
const { user } = createRender()
await openPopover(user)
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('should display all options sorted alphabetically', async () => {
const { user } = createRender({
chip: createMockChip(['MODEL', 'IMAGE', 'LATENT'])
})
await openPopover(user)
const options = screen.getAllByRole('option')
expect(options).toHaveLength(3)
const texts = options.map((o) => o.textContent?.trim())
expect(texts[0]).toContain('IMAGE')
expect(texts[1]).toContain('LATENT')
expect(texts[2]).toContain('MODEL')
})
it('should show selected count text', async () => {
const { user } = createRender({ selectedValues: ['IMAGE', 'LATENT'] })
await openPopover(user)
expect(screen.getByText(/2 items selected/i)).toBeInTheDocument()
})
it('should show clear all button only when values are selected', async () => {
const { user } = createRender({ selectedValues: [] })
await openPopover(user)
const buttons = screen.getAllByRole('button')
const clearBtn = buttons.find((b) => b.textContent?.includes('Clear all'))
expect(clearBtn).toBeUndefined()
})
it('should show clear all button when values are selected', async () => {
const { user } = createRender({ selectedValues: ['IMAGE'] })
await openPopover(user)
expect(
screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Clear all'))
).toBeTruthy()
})
it('should emit clear when clear all button is clicked', async () => {
const { user, onClear } = createRender({ selectedValues: ['IMAGE'] })
await openPopover(user)
const clearBtn = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Clear all'))!
await user.click(clearBtn)
await nextTick()
expect(onClear).toHaveBeenCalledOnce()
})
it('should emit toggle when an option is clicked', async () => {
const { user, onToggle } = createRender()
await openPopover(user)
await user.click(screen.getAllByRole('option')[0])
await nextTick()
expect(onToggle).toHaveBeenCalledWith('IMAGE')
})
it('should filter options via search input', async () => {
const { user } = createRender()
await openPopover(user)
await user.type(screen.getByRole('textbox'), 'IMAGE')
await nextTick()
const options = screen.getAllByRole('option')
expect(options).toHaveLength(1)
expect(options[0].textContent).toContain('IMAGE')
})
it('should show no results when search matches nothing', async () => {
const { user } = createRender()
await openPopover(user)
await user.type(screen.getByRole('textbox'), 'NONEXISTENT')
await nextTick()
expect(screen.queryAllByRole('option')).toHaveLength(0)
expect(screen.getByText('No Results')).toBeInTheDocument()
})
it('should emit escapeClose and close the popover when Escape is pressed', async () => {
const { user, onEscapeClose } = createRender()
await openPopover(user)
expect(screen.getByRole('listbox')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
expect(onEscapeClose).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,175 @@
<template>
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="4"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down.prevent
@keydown.escape.stop="closeWithEscape"
>
<ListboxRoot
multiple
selection-behavior="toggle"
:model-value="selectedValues"
@update:model-value="onSelectionChange"
>
<div
class="mt-2 flex h-8 items-center gap-2 rounded-sm border border-border-default px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<ListboxFilter
ref="searchFilterRef"
v-model="searchQuery"
:placeholder="t('g.search')"
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-sm text-muted-foreground">
{{
t(
'g.itemsSelected',
{ count: selectedValues.length },
selectedValues.length
)
}}
</span>
<button
v-if="selectedValues.length > 0"
type="button"
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
@click="emit('clear')"
>
{{ t('g.clearAll') }}
</button>
</div>
<div class="h-px bg-border-default" />
<ListboxContent class="max-h-64 overflow-y-auto py-3">
<ListboxItem
v-for="option in filteredOptions"
:key="option"
:value="option"
data-testid="filter-option"
class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover"
>
<span
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
selectedSet.has(option) &&
'text-primary-foreground border-primary bg-primary'
)
"
>
<i
v-if="selectedSet.has(option)"
class="icon-[lucide--check] size-3"
/>
</span>
<span class="truncate">{{ option }}</span>
<span
class="mr-1 ml-auto text-lg leading-none"
:style="{ color: getLinkTypeColor(option) }"
>
&bull;
</span>
</ListboxItem>
<div
v-if="filteredOptions.length === 0"
class="px-1 py-4 text-center text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</ListboxContent>
</ListboxRoot>
</PopoverContent>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AcceptableValue } from 'reka-ui'
import {
ListboxContent,
ListboxFilter,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import { getLinkTypeColor } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { chip, selectedValues } = defineProps<{
chip: FilterChip
selectedValues: string[]
}>()
const emit = defineEmits<{
toggle: [value: string]
clear: []
escapeClose: []
}>()
const { t } = useI18n()
const open = ref(false)
const closedWithEscape = ref(false)
const searchQuery = ref('')
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
function onOpenChange(isOpen: boolean) {
if (!isOpen) searchQuery.value = ''
}
const selectedSet = computed(() => new Set(selectedValues))
function onSelectionChange(value: AcceptableValue) {
const newValues = value as string[]
const added = newValues.find((v) => !selectedSet.value.has(v))
const removed = selectedValues.find((v) => !newValues.includes(v))
const toggled = added ?? removed
if (toggled) emit('toggle', toggled)
}
const filteredOptions = computed(() => {
const { fuseSearch } = chip.filter
if (searchQuery.value) {
return fuseSearch.search(searchQuery.value).slice(0, 64)
}
return fuseSearch.data.slice().sort()
})
function closeWithEscape() {
closedWithEscape.value = true
open.value = false
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
el?.focus()
}
function onCloseAutoFocus(event: Event) {
if (closedWithEscape.value) {
event.preventDefault()
closedWithEscape.value = false
emit('escapeClose')
}
}
</script>

View File

@@ -2,12 +2,14 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export function createMockNodeDef(
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef {
return {
): ComfyNodeDefImpl {
return new ComfyNodeDefImpl({
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
@@ -21,7 +23,7 @@ export function createMockNodeDef(
deprecated: false,
experimental: false,
...overrides
}
})
}
export function setupTestPinia() {
@@ -31,34 +33,5 @@ export function setupTestPinia() {
export const testI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
addNode: 'Add a node...',
filterBy: 'Filter by:',
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
essentials: 'Essentials',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
extensions: 'Extensions',
noResults: 'No results',
filterByType: 'Filter by {type}...',
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
}
}
}
messages: { en: enMessages }
})

View File

@@ -0,0 +1,513 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { GPUBrushRenderer } from './GPUBrushRenderer'
// WebGPU globals are not available in happy-dom
beforeAll(() => {
vi.stubGlobal('GPUBufferUsage', {
VERTEX: 0x0020,
INDEX: 0x0010,
COPY_DST: 0x0008,
UNIFORM: 0x0040
})
vi.stubGlobal('GPUTextureUsage', {
RENDER_ATTACHMENT: 0x0010,
TEXTURE_BINDING: 0x0004,
COPY_SRC: 0x0001
})
vi.stubGlobal('GPUShaderStage', { VERTEX: 0x1, FRAGMENT: 0x2 })
})
afterAll(() => {
vi.unstubAllGlobals()
})
vi.mock('typegpu', () => ({
tgpu: { resolve: vi.fn(() => '/* mock wgsl */') }
}))
vi.mock('typegpu/data', () => ({
struct: vi.fn(() => ({})),
vec2f: {},
vec3f: {},
f32: {},
u32: {},
location: vi.fn(() => ({})),
builtin: { position: {} },
sizeOf: vi.fn(() => 16)
}))
let pipelineCounter = 0
function createMockPass() {
return {
setPipeline: vi.fn(),
setBindGroup: vi.fn(),
setVertexBuffer: vi.fn(),
setIndexBuffer: vi.fn(),
drawIndexed: vi.fn(),
draw: vi.fn(),
dispatchWorkgroups: vi.fn(),
end: vi.fn()
}
}
function createMockEncoder() {
const renderPass = createMockPass()
const computePass = createMockPass()
return {
beginRenderPass: vi.fn(() => renderPass),
beginComputePass: vi.fn(() => computePass),
finish: vi.fn(() => 'command-buffer'),
_renderPass: renderPass,
_computePass: computePass
}
}
function createMockTexture(
width = 512,
height = 512
): GPUTexture & { _view: GPUTextureView } {
const view = {} as GPUTextureView
return {
width,
height,
createView: vi.fn(() => view),
destroy: vi.fn(),
_view: view
} as unknown as GPUTexture & { _view: GPUTextureView }
}
function createMockDevice() {
const encoder = createMockEncoder()
const device = {
createBuffer: vi.fn((desc: GPUBufferDescriptor) => ({
size: desc.size,
getMappedRange: vi.fn(() => new ArrayBuffer(desc.size)),
unmap: vi.fn(),
destroy: vi.fn()
})),
createShaderModule: vi.fn(() => ({})),
createBindGroupLayout: vi.fn(() => ({})),
createBindGroup: vi.fn(() => ({})),
createPipelineLayout: vi.fn(() => ({})),
createRenderPipeline: vi.fn(() => ({
_id: `pipeline-${pipelineCounter++}`
})),
createComputePipeline: vi.fn(
() =>
({
getBindGroupLayout: vi.fn(() => ({}))
}) as unknown as GPUComputePipeline
),
createTexture: vi.fn((desc: { size: number[] }) =>
createMockTexture(desc.size[0], desc.size[1])
),
createCommandEncoder: vi.fn(() => encoder),
queue: {
writeBuffer: vi.fn(),
submit: vi.fn()
},
_encoder: encoder
}
return device as unknown as GPUDevice & {
_encoder: ReturnType<typeof createMockEncoder>
}
}
describe('GPUBrushRenderer', () => {
// Pipeline creation order in constructor:
// 0: render, 1: accumulate, 2: blit,
// 3: composite, 4: compositePreview, 5: erase, 6: erasePreview
const PIPELINE_INDEX = {
composite: 3,
compositePreview: 4,
erase: 5,
erasePreview: 6
}
function getPipeline(index: number) {
return (device.createRenderPipeline as ReturnType<typeof vi.fn>).mock
.results[index].value
}
let device: ReturnType<typeof createMockDevice>
let renderer: GPUBrushRenderer
beforeEach(() => {
vi.clearAllMocks()
pipelineCounter = 0
device = createMockDevice()
renderer = new GPUBrushRenderer(device)
})
describe('constructor', () => {
it('creates required GPU buffers', () => {
// quad vertex, index, instance, uniform = 4 buffers
expect(device.createBuffer).toHaveBeenCalledTimes(4)
})
it('creates shader modules', () => {
// brushVertex, brushFragment, blit, composite (×4), readback = 8
expect(device.createShaderModule).toHaveBeenCalled()
})
it('creates bind group layouts for uniforms and textures', () => {
expect(device.createBindGroupLayout).toHaveBeenCalledTimes(2)
})
it('creates all render and compute pipelines', () => {
// render, accumulate, blit, composite, compositePreview,
// erase, erasePreview = 7 render pipelines
expect(device.createRenderPipeline).toHaveBeenCalledTimes(7)
// readback = 1 compute pipeline
expect(device.createComputePipeline).toHaveBeenCalledTimes(1)
})
})
describe('prepareStroke', () => {
it('creates a new texture when none exists', () => {
renderer.prepareStroke(256, 256)
expect(device.createTexture).toHaveBeenCalledWith(
expect.objectContaining({ size: [256, 256], format: 'rgba8unorm' })
)
})
it('clears the accumulation texture via a render pass', () => {
renderer.prepareStroke(256, 256)
const encoder = device._encoder
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
expect.objectContaining({
colorAttachments: expect.arrayContaining([
expect.objectContaining({ loadOp: 'clear' })
])
})
)
expect(encoder._renderPass.end).toHaveBeenCalled()
expect(device.queue.submit).toHaveBeenCalled()
})
it('reuses the texture when dimensions match', () => {
renderer.prepareStroke(256, 256)
const callCount = (device.createTexture as ReturnType<typeof vi.fn>).mock
.calls.length
renderer.prepareStroke(256, 256)
expect(device.createTexture).toHaveBeenCalledTimes(callCount)
})
it('recreates the texture when dimensions change', () => {
renderer.prepareStroke(256, 256)
renderer.prepareStroke(512, 512)
expect(device.createTexture).toHaveBeenCalledTimes(2)
})
})
describe('renderStrokeToAccumulator', () => {
const settings = {
size: 10,
opacity: 1,
hardness: 0.5,
color: [1, 1, 1] as [number, number, number],
width: 256,
height: 256,
brushShape: 0
}
it('does nothing when no stroke texture is prepared', () => {
renderer.renderStrokeToAccumulator(
[{ x: 0, y: 0, pressure: 1 }],
settings
)
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
})
it('writes uniforms and instance data then submits', () => {
renderer.prepareStroke(256, 256)
vi.clearAllMocks()
const points = [
{ x: 10, y: 20, pressure: 0.5 },
{ x: 30, y: 40, pressure: 1.0 }
]
renderer.renderStrokeToAccumulator(points, settings)
// uniform + instance data = 2 writeBuffer calls
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(2)
expect(device.queue.submit).toHaveBeenCalled()
})
it('does not render when points array is empty', () => {
renderer.prepareStroke(256, 256)
vi.clearAllMocks()
renderer.renderStrokeToAccumulator([], settings)
// writeBuffer is never called because renderStrokeInternal returns early
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
})
})
describe('renderStroke', () => {
const settings = {
size: 10,
opacity: 1,
hardness: 0.5,
color: [1, 0, 0] as [number, number, number],
width: 512,
height: 512,
brushShape: 0
}
it('renders points directly to the target view', () => {
const targetView = {} as GPUTextureView
const points = [{ x: 5, y: 5, pressure: 1 }]
renderer.renderStroke(targetView, points, settings)
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(2)
const encoder = device._encoder
expect(encoder._renderPass.setPipeline).toHaveBeenCalled()
expect(encoder._renderPass.drawIndexed).toHaveBeenCalledWith(6, 1)
})
it('skips rendering for empty points', () => {
const targetView = {} as GPUTextureView
renderer.renderStroke(targetView, [], settings)
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
})
})
describe('compositeStroke', () => {
const settings = {
opacity: 0.8,
color: [1, 0, 0] as [number, number, number],
hardness: 0.5,
screenSize: [512, 512] as [number, number],
brushShape: 0
}
it('does nothing when no stroke texture exists', () => {
const targetView = {} as GPUTextureView
renderer.compositeStroke(targetView, settings)
expect(device.queue.writeBuffer).not.toHaveBeenCalled()
})
it('writes uniforms and submits a composite pass', () => {
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
const targetView = {} as GPUTextureView
renderer.compositeStroke(targetView, settings)
expect(device.queue.writeBuffer).toHaveBeenCalledTimes(1)
expect(device.createBindGroup).toHaveBeenCalled()
expect(device.queue.submit).toHaveBeenCalled()
})
it('uses erase pipeline when isErasing is true', () => {
const erasePipeline = getPipeline(PIPELINE_INDEX.erase)
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
const targetView = {} as GPUTextureView
renderer.compositeStroke(targetView, { ...settings, isErasing: true })
const encoder = device._encoder
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
erasePipeline
)
expect(encoder._renderPass.draw).toHaveBeenCalledWith(3)
})
it('uses composite pipeline when isErasing is false', () => {
const compositePipeline = getPipeline(PIPELINE_INDEX.composite)
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
const targetView = {} as GPUTextureView
renderer.compositeStroke(targetView, settings)
const encoder = device._encoder
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
compositePipeline
)
})
})
describe('blitToCanvas', () => {
const mockCtx = {
getCurrentTexture: vi.fn(() => createMockTexture())
} as unknown as GPUCanvasContext
const settings = {
opacity: 1,
color: [1, 1, 1] as [number, number, number],
hardness: 0.5,
screenSize: [512, 512] as [number, number],
brushShape: 0
}
it('clears destination when no background texture is provided', () => {
renderer.blitToCanvas(mockCtx, settings)
const encoder = device._encoder
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
expect.objectContaining({
colorAttachments: expect.arrayContaining([
expect.objectContaining({ loadOp: 'clear' })
])
})
)
expect(device.queue.submit).toHaveBeenCalled()
})
it('draws background texture when provided', () => {
const bgTexture = createMockTexture()
renderer.blitToCanvas(mockCtx, settings, bgTexture)
expect(device.createBindGroup).toHaveBeenCalled()
expect(device.queue.submit).toHaveBeenCalled()
})
it('composites the stroke texture when prepared', () => {
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
renderer.blitToCanvas(mockCtx, settings)
// Writes uniforms for the preview pass
expect(device.queue.writeBuffer).toHaveBeenCalled()
expect(device.queue.submit).toHaveBeenCalled()
})
it('uses erase preview pipeline when isErasing is true', () => {
const erasePreviewPipeline = getPipeline(PIPELINE_INDEX.erasePreview)
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
renderer.blitToCanvas(mockCtx, { ...settings, isErasing: true })
const encoder = device._encoder
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
erasePreviewPipeline
)
})
it('uses composite preview pipeline when isErasing is false', () => {
const compositePreviewPipeline = getPipeline(
PIPELINE_INDEX.compositePreview
)
renderer.prepareStroke(512, 512)
vi.clearAllMocks()
renderer.blitToCanvas(mockCtx, settings)
const encoder = device._encoder
expect(encoder._renderPass.setPipeline).toHaveBeenCalledWith(
compositePreviewPipeline
)
})
})
describe('clearPreview', () => {
it('submits a clear render pass', () => {
const mockCtx = {
getCurrentTexture: vi.fn(() => createMockTexture())
} as unknown as GPUCanvasContext
renderer.clearPreview(mockCtx)
const encoder = device._encoder
expect(encoder.beginRenderPass).toHaveBeenCalledWith(
expect.objectContaining({
colorAttachments: expect.arrayContaining([
expect.objectContaining({
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 }
})
])
})
)
expect(encoder._renderPass.end).toHaveBeenCalled()
expect(device.queue.submit).toHaveBeenCalled()
})
})
describe('prepareReadback', () => {
it('creates a bind group and dispatches a compute pass', () => {
const texture = createMockTexture(64, 64)
const outputBuffer = { size: 64 * 64 * 4 } as GPUBuffer
renderer.prepareReadback(texture, outputBuffer)
expect(device.queue.submit).toHaveBeenCalled()
const encoder = device._encoder
expect(encoder.beginComputePass).toHaveBeenCalled()
expect(encoder._computePass.setPipeline).toHaveBeenCalled()
expect(encoder._computePass.dispatchWorkgroups).toHaveBeenCalledWith(
Math.ceil(64 / 8),
Math.ceil(64 / 8)
)
})
it('reuses the bind group for the same texture and buffer', () => {
const texture = createMockTexture(64, 64)
const outputBuffer = { size: 64 * 64 * 4 } as GPUBuffer
renderer.prepareReadback(texture, outputBuffer)
const firstCallCount = (
device.createBindGroup as ReturnType<typeof vi.fn>
).mock.calls.length
renderer.prepareReadback(texture, outputBuffer)
// +1 from constructor for mainUniformBindGroup is already counted
expect(device.createBindGroup).toHaveBeenCalledTimes(firstCallCount)
})
it('recreates the bind group when texture changes', () => {
const texture1 = createMockTexture(64, 64)
const texture2 = createMockTexture(128, 128)
const outputBuffer = { size: 128 * 128 * 4 } as GPUBuffer
renderer.prepareReadback(texture1, outputBuffer)
const afterFirst = (device.createBindGroup as ReturnType<typeof vi.fn>)
.mock.calls.length
renderer.prepareReadback(texture2, outputBuffer)
expect(device.createBindGroup).toHaveBeenCalledTimes(afterFirst + 1)
})
})
describe('destroy', () => {
it('destroys all GPU buffers', () => {
renderer.destroy()
// 4 buffers created in constructor
const buffers = (device.createBuffer as ReturnType<typeof vi.fn>).mock
.results
for (const result of buffers) {
expect(result.value.destroy).toHaveBeenCalled()
}
})
it('destroys the stroke texture if one was created', () => {
renderer.prepareStroke(256, 256)
const texture = (device.createTexture as ReturnType<typeof vi.fn>).mock
.results[0].value
renderer.destroy()
expect(texture.destroy).toHaveBeenCalled()
})
it('does not throw when no stroke texture exists', () => {
expect(() => renderer.destroy()).not.toThrow()
})
})
})

View File

@@ -0,0 +1,427 @@
import { describe, expect, it } from 'vitest'
import {
calculateDragPan,
calculateFitView,
calculatePanZoomStyles,
calculateSingleTouchPan,
calculateZoomAroundPoint,
clampZoom,
easeOutCubic,
getCursorPoint,
getDistanceBetweenPoints,
getMidpoint,
getWheelZoomFactor,
interpolateView,
isDoubleTap
} from './panZoomUtils'
describe('panZoomUtils', () => {
describe('getDistanceBetweenPoints', () => {
it('returns 0 for same point', () => {
expect(getDistanceBetweenPoints({ x: 5, y: 5 }, { x: 5, y: 5 })).toBe(0)
})
it('calculates horizontal distance', () => {
expect(getDistanceBetweenPoints({ x: 0, y: 0 }, { x: 3, y: 0 })).toBe(3)
})
it('calculates diagonal distance', () => {
expect(getDistanceBetweenPoints({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5)
})
})
describe('getMidpoint', () => {
it('returns midpoint of two points', () => {
expect(getMidpoint({ x: 0, y: 0 }, { x: 10, y: 20 })).toEqual({
x: 5,
y: 10
})
})
it('returns same point when both are identical', () => {
expect(getMidpoint({ x: 7, y: 3 }, { x: 7, y: 3 })).toEqual({
x: 7,
y: 3
})
})
})
describe('clampZoom', () => {
it('returns value within bounds unchanged', () => {
expect(clampZoom(1)).toBe(1)
expect(clampZoom(5)).toBe(5)
})
it('clamps to minimum 0.2', () => {
expect(clampZoom(0.1)).toBe(0.2)
expect(clampZoom(0)).toBe(0.2)
expect(clampZoom(-5)).toBe(0.2)
})
it('clamps to maximum 10.0', () => {
expect(clampZoom(15)).toBe(10)
expect(clampZoom(10.1)).toBe(10)
})
it('preserves boundary values exactly', () => {
expect(clampZoom(0.2)).toBe(0.2)
expect(clampZoom(10)).toBe(10)
})
})
describe('getWheelZoomFactor', () => {
it('returns 1.1 for negative deltaY (scroll up = zoom in)', () => {
expect(getWheelZoomFactor(-100)).toBe(1.1)
expect(getWheelZoomFactor(-1)).toBe(1.1)
})
it('returns 0.9 for positive deltaY (scroll down = zoom out)', () => {
expect(getWheelZoomFactor(100)).toBe(0.9)
expect(getWheelZoomFactor(1)).toBe(0.9)
})
it('returns 0.9 for zero deltaY', () => {
expect(getWheelZoomFactor(0)).toBe(0.9)
})
})
describe('calculateZoomAroundPoint', () => {
it('zooms in and adjusts pan toward focal point', () => {
const result = calculateZoomAroundPoint(
1.0,
1.1,
{ x: 0, y: 0 },
400,
300
)
expect(result.zoomRatio).toBeCloseTo(1.1)
expect(result.panOffset.x).toBeLessThan(0)
expect(result.panOffset.y).toBeLessThan(0)
})
it('zooms out and adjusts pan away from focal point', () => {
const result = calculateZoomAroundPoint(
1.0,
0.9,
{ x: 0, y: 0 },
400,
300
)
expect(result.zoomRatio).toBeCloseTo(0.9)
expect(result.panOffset.x).toBeGreaterThan(0)
expect(result.panOffset.y).toBeGreaterThan(0)
})
it('clamps zoom to upper bound', () => {
const result = calculateZoomAroundPoint(9.5, 2.0, { x: 0, y: 0 }, 0, 0)
expect(result.zoomRatio).toBe(10)
})
it('clamps zoom to lower bound', () => {
const result = calculateZoomAroundPoint(0.3, 0.5, { x: 0, y: 0 }, 0, 0)
expect(result.zoomRatio).toBe(0.2)
})
it('does not shift pan when focal point is at origin', () => {
const result = calculateZoomAroundPoint(
1.0,
1.5,
{ x: 100, y: 200 },
0,
0
)
expect(result.panOffset.x).toBe(100)
expect(result.panOffset.y).toBe(200)
})
})
describe('calculateDragPan', () => {
it('returns initial pan when no movement', () => {
const result = calculateDragPan(
{ x: 100, y: 200 },
{ x: 100, y: 200 },
{ x: 50, y: 60 }
)
expect(result).toEqual({ x: 50, y: 60 })
})
it('offsets pan by mouse delta', () => {
const result = calculateDragPan(
{ x: 100, y: 200 },
{ x: 150, y: 250 },
{ x: 0, y: 0 }
)
expect(result).toEqual({ x: 50, y: 50 })
})
it('preserves initial pan as base', () => {
const result = calculateDragPan(
{ x: 100, y: 200 },
{ x: 80, y: 180 },
{ x: 300, y: 400 }
)
expect(result).toEqual({ x: 280, y: 380 })
})
})
describe('calculateSingleTouchPan', () => {
it('returns unchanged pan when no movement', () => {
const result = calculateSingleTouchPan(
{ x: 100, y: 200 },
{ x: 100, y: 200 },
{ x: 50, y: 60 }
)
expect(result).toEqual({ x: 50, y: 60 })
})
it('adds touch delta to pan', () => {
const result = calculateSingleTouchPan(
{ x: 100, y: 200 },
{ x: 150, y: 250 },
{ x: 10, y: 20 }
)
expect(result).toEqual({ x: 60, y: 70 })
})
})
describe('getCursorPoint', () => {
it('subtracts pan offset from client point', () => {
expect(getCursorPoint({ x: 500, y: 400 }, { x: 100, y: 50 })).toEqual({
x: 400,
y: 350
})
})
it('returns client point when offset is zero', () => {
expect(getCursorPoint({ x: 200, y: 300 }, { x: 0, y: 0 })).toEqual({
x: 200,
y: 300
})
})
})
describe('isDoubleTap', () => {
it('returns true when within delay', () => {
expect(isDoubleTap(1100, 1000, 300)).toBe(true)
})
it('returns false when outside delay', () => {
expect(isDoubleTap(1500, 1000, 300)).toBe(false)
})
it('returns false when exactly at delay boundary', () => {
expect(isDoubleTap(1300, 1000, 300)).toBe(false)
})
it('returns true when lastTapTime is 0 and currentTime is small', () => {
expect(isDoubleTap(100, 0, 300)).toBe(true)
})
})
describe('easeOutCubic', () => {
it('returns 0 at start', () => {
expect(easeOutCubic(0)).toBe(0)
})
it('returns 1 at end', () => {
expect(easeOutCubic(1)).toBe(1)
})
it('returns value between 0 and 1 for midpoint', () => {
const mid = easeOutCubic(0.5)
expect(mid).toBeGreaterThan(0)
expect(mid).toBeLessThan(1)
})
it('decelerates (second half has less change than first)', () => {
const firstHalf = easeOutCubic(0.5)
const secondHalf = easeOutCubic(1) - easeOutCubic(0.5)
expect(firstHalf).toBeGreaterThan(secondHalf)
})
})
describe('interpolateView', () => {
it('returns start values at progress 0', () => {
const result = interpolateView(
1,
2,
{ x: 0, y: 0 },
{ x: 100, y: 200 },
0
)
expect(result.zoomRatio).toBe(1)
expect(result.panOffset).toEqual({ x: 0, y: 0 })
})
it('returns target values at progress 1', () => {
const result = interpolateView(
1,
2,
{ x: 0, y: 0 },
{ x: 100, y: 200 },
1
)
expect(result.zoomRatio).toBe(2)
expect(result.panOffset).toEqual({ x: 100, y: 200 })
})
it('returns halfway values at progress 0.5', () => {
const result = interpolateView(
1,
3,
{ x: 0, y: 0 },
{ x: 100, y: 200 },
0.5
)
expect(result.zoomRatio).toBe(2)
expect(result.panOffset).toEqual({ x: 50, y: 100 })
})
})
describe('calculateFitView', () => {
it('fits landscape image width-constrained', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 1000,
imageHeight: 500,
toolPanelWidth: 64,
sidePanelWidth: 220
})
const availableWidth = 1200 - 64 - 220
expect(result.zoomRatio).toBeCloseTo(availableWidth / 1000)
expect(result.fittedWidth).toBeCloseTo(availableWidth)
expect(result.panOffset.x).toBe(64)
})
it('fits portrait image height-constrained', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 400,
imageHeight: 1000,
toolPanelWidth: 64,
sidePanelWidth: 220
})
expect(result.zoomRatio).toBeCloseTo(800 / 1000)
expect(result.fittedHeight).toBeCloseTo(800)
})
it('centers vertically for width-constrained images', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 1000,
imageHeight: 500,
toolPanelWidth: 64,
sidePanelWidth: 220
})
expect(result.panOffset.y).toBeGreaterThan(0)
})
it('centers horizontally for height-constrained images', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 400,
imageHeight: 1000,
toolPanelWidth: 64,
sidePanelWidth: 220
})
expect(result.panOffset.x).toBeGreaterThan(64)
})
it('accounts for panel widths in available space', () => {
const withPanels = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 800,
imageHeight: 600,
toolPanelWidth: 64,
sidePanelWidth: 220
})
const withoutPanels = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 800,
imageHeight: 600,
toolPanelWidth: 0,
sidePanelWidth: 0
})
expect(withPanels.zoomRatio).toBeLessThan(withoutPanels.zoomRatio)
expect(withPanels.panOffset.x).toBeGreaterThanOrEqual(64)
})
it('offsets pan.x by exactly toolPanelWidth for width-constrained fit', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 2000,
imageHeight: 500,
toolPanelWidth: 80,
sidePanelWidth: 200
})
expect(result.panOffset.x).toBe(80)
})
it('offsets pan.x by toolPanelWidth + centering for height-constrained fit', () => {
const result = calculateFitView({
rootWidth: 1200,
rootHeight: 800,
imageWidth: 400,
imageHeight: 1000,
toolPanelWidth: 64,
sidePanelWidth: 220
})
const availableWidth = 1200 - 64 - 220
const expectedX = (availableWidth - result.fittedWidth) / 2 + 64
expect(result.panOffset.x).toBeCloseTo(expectedX)
})
})
describe('calculatePanZoomStyles', () => {
it('computes container styles from zoom and offset', () => {
const result = calculatePanZoomStyles(800, 600, 1.5, {
x: 100,
y: 50
})
expect(result.rawWidth).toBe(1200)
expect(result.rawHeight).toBe(900)
expect(result.containerWidth).toBe('1200px')
expect(result.containerHeight).toBe('900px')
expect(result.containerLeft).toBe('100px')
expect(result.containerTop).toBe('50px')
})
it('handles zoom ratio of 1', () => {
const result = calculatePanZoomStyles(800, 600, 1, { x: 0, y: 0 })
expect(result.rawWidth).toBe(800)
expect(result.rawHeight).toBe(600)
})
})
})

View File

@@ -0,0 +1,178 @@
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
const ZOOM_MIN = 0.2
const ZOOM_MAX = 10.0
interface FitViewParams {
rootWidth: number
rootHeight: number
imageWidth: number
imageHeight: number
toolPanelWidth: number
sidePanelWidth: number
}
interface FitViewResult {
zoomRatio: number
panOffset: Offset
fittedWidth: number
fittedHeight: number
}
export function calculateFitView(params: FitViewParams): FitViewResult {
const {
rootWidth,
rootHeight,
imageWidth,
imageHeight,
toolPanelWidth,
sidePanelWidth
} = params
const availableWidth = rootWidth - sidePanelWidth - toolPanelWidth
const availableHeight = rootHeight
const zoomRatioWidth = availableWidth / imageWidth
const zoomRatioHeight = availableHeight / imageHeight
const zoomRatio = Math.min(zoomRatioWidth, zoomRatioHeight)
const aspectRatio = imageWidth / imageHeight
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
let fittedWidth: number
let fittedHeight: number
if (zoomRatioHeight > zoomRatioWidth) {
fittedWidth = availableWidth
fittedHeight = fittedWidth / aspectRatio
panOffset.y = (availableHeight - fittedHeight) / 2
} else {
fittedHeight = availableHeight
fittedWidth = fittedHeight * aspectRatio
panOffset.x = (availableWidth - fittedWidth) / 2 + toolPanelWidth
}
return { zoomRatio, panOffset, fittedWidth, fittedHeight }
}
export function clampZoom(zoom: number): number {
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom))
}
export function getWheelZoomFactor(deltaY: number): number {
return deltaY < 0 ? 1.1 : 0.9
}
export function calculateZoomAroundPoint(
currentZoom: number,
zoomFactor: number,
panOffset: Offset,
focalX: number,
focalY: number
): { zoomRatio: number; panOffset: Offset } {
const newZoom = clampZoom(currentZoom * zoomFactor)
const scaleFactor = newZoom / currentZoom
return {
zoomRatio: newZoom,
panOffset: {
x: panOffset.x + focalX - focalX * scaleFactor,
y: panOffset.y + focalY - focalY * scaleFactor
}
}
}
export function calculateDragPan(
mouseDownPoint: Point,
currentPoint: Point,
initialPan: Offset
): Offset {
return {
x: initialPan.x - (mouseDownPoint.x - currentPoint.x),
y: initialPan.y - (mouseDownPoint.y - currentPoint.y)
}
}
export function calculateSingleTouchPan(
lastPoint: Point,
currentPoint: Point,
panOffset: Offset
): Offset {
return {
x: panOffset.x + (currentPoint.x - lastPoint.x),
y: panOffset.y + (currentPoint.y - lastPoint.y)
}
}
export function getDistanceBetweenPoints(a: Point, b: Point): number {
const dx = a.x - b.x
const dy = a.y - b.y
return Math.sqrt(dx * dx + dy * dy)
}
export function getMidpoint(a: Point, b: Point): Point {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2
}
}
export function getCursorPoint(clientPoint: Point, panOffset: Offset): Point {
return {
x: clientPoint.x - panOffset.x,
y: clientPoint.y - panOffset.y
}
}
export function isDoubleTap(
currentTime: number,
lastTapTime: number,
delay: number
): boolean {
return currentTime - lastTapTime < delay
}
export function easeOutCubic(progress: number): number {
return 1 - Math.pow(1 - progress, 3)
}
export function interpolateView(
startZoom: number,
targetZoom: number,
startPan: Offset,
targetPan: Offset,
easedProgress: number
): { zoomRatio: number; panOffset: Offset } {
return {
zoomRatio: startZoom + (targetZoom - startZoom) * easedProgress,
panOffset: {
x: startPan.x + (targetPan.x - startPan.x) * easedProgress,
y: startPan.y + (targetPan.y - startPan.y) * easedProgress
}
}
}
export function calculatePanZoomStyles(
imageWidth: number,
imageHeight: number,
zoomRatio: number,
panOffset: Offset
): {
rawWidth: number
rawHeight: number
containerLeft: string
containerTop: string
containerWidth: string
containerHeight: string
} {
const rawWidth = imageWidth * zoomRatio
const rawHeight = imageHeight * zoomRatio
return {
rawWidth,
rawHeight,
containerLeft: `${panOffset.x}px`,
containerTop: `${panOffset.y}px`,
containerWidth: `${rawWidth}px`,
containerHeight: `${rawHeight}px`
}
}

View File

@@ -0,0 +1,459 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
interface IMockStore {
canvasContainer: HTMLElement | null
maskCanvas: HTMLCanvasElement | null
rgbCanvas: HTMLCanvasElement | null
isPanning: boolean
brushVisible: boolean
displayZoomRatio: number
resetZoomTrigger: number
canvasHistory: { undo: ReturnType<typeof vi.fn> }
setCursorPoint: ReturnType<typeof vi.fn>
setPanOffset: ReturnType<typeof vi.fn>
setZoomRatio: ReturnType<typeof vi.fn>
}
const { mockStore } = vi.hoisted(() => {
const mockStore: IMockStore = {
canvasContainer: null,
maskCanvas: null,
rgbCanvas: null,
isPanning: false,
brushVisible: true,
displayZoomRatio: 1,
resetZoomTrigger: 0,
canvasHistory: { undo: vi.fn() },
setCursorPoint: vi.fn(),
setPanOffset: vi.fn(),
setZoomRatio: vi.fn()
}
return { mockStore }
})
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
function createMockElement(width = 1200, height = 800): HTMLElement {
return {
clientWidth: width,
clientHeight: height,
style: {} as CSSStyleDeclaration,
getBoundingClientRect: () =>
({
left: 0,
top: 0,
width,
height,
right: width,
bottom: height
}) as DOMRect
} as unknown as HTMLElement
}
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
return {
width,
height,
clientWidth: width,
clientHeight: height,
style: {} as CSSStyleDeclaration,
getBoundingClientRect: () =>
({
left: 0,
top: 0,
width,
height,
right: width,
bottom: height
}) as DOMRect
} as unknown as HTMLCanvasElement
}
function createMockImage(width: number, height: number): HTMLImageElement {
return { width, height } as HTMLImageElement
}
function createTouchList(...points: { x: number; y: number }[]): TouchList {
const touches = points.map((p) => ({ clientX: p.x, clientY: p.y }) as Touch)
return Object.assign(touches, {
length: touches.length,
item: (i: number) => touches[i]
}) as unknown as TouchList
}
function createTouchEvent(touches: TouchList): TouchEvent {
return {
touches,
preventDefault: vi.fn()
} as unknown as TouchEvent
}
async function initComposable() {
const pz = usePanAndZoom()
const img = createMockImage(800, 600)
const root = createMockElement()
const container = createMockElement()
const canvas = createMockCanvas(800, 600)
mockStore.canvasContainer = container as unknown as HTMLElement
mockStore.maskCanvas = canvas
await pz.initializeCanvasPanZoom(img, root)
vi.clearAllMocks()
return { pz, canvas }
}
describe('usePanAndZoom', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
mockStore.canvasContainer = null
mockStore.maskCanvas = null
mockStore.rgbCanvas = null
mockStore.isPanning = false
mockStore.brushVisible = true
mockStore.displayZoomRatio = 1
mockStore.resetZoomTrigger = 0
})
afterEach(() => {
vi.useRealTimers()
})
describe('initializeCanvasPanZoom', () => {
it('sets zoom and pan on the store', async () => {
const pz = usePanAndZoom()
const container = createMockElement()
mockStore.canvasContainer = container as unknown as HTMLElement
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(mockStore.setZoomRatio).toHaveBeenCalledOnce()
expect(mockStore.setPanOffset).toHaveBeenCalledOnce()
const zoom = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
expect(zoom).toBeGreaterThan(0)
})
it('accounts for panel widths via setPanOffset', async () => {
const pz = usePanAndZoom()
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
const toolPanel = createMockElement()
vi.spyOn(toolPanel, 'getBoundingClientRect').mockReturnValue({
width: 64
} as DOMRect)
const sidePanel = createMockElement()
vi.spyOn(sidePanel, 'getBoundingClientRect').mockReturnValue({
width: 220
} as DOMRect)
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement(),
toolPanel,
sidePanel
)
const offset = vi.mocked(mockStore.setPanOffset).mock.calls[0][0]
expect(offset.x).toBeGreaterThanOrEqual(64)
})
it('syncs rgbCanvas dimensions when they differ', async () => {
const pz = usePanAndZoom()
const rgbCanvas = createMockCanvas(400, 300)
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
mockStore.rgbCanvas = rgbCanvas
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
expect(rgbCanvas.width).toBe(800)
expect(rgbCanvas.height).toBe(600)
})
})
describe('handlePanStart / handlePanMove', () => {
it('sets isPanning on the store', () => {
const pz = usePanAndZoom()
pz.handlePanStart({ clientX: 100, clientY: 200 } as PointerEvent)
expect(mockStore.isPanning).toBe(true)
})
it('updates pan offset on move', async () => {
const { pz } = await initComposable()
pz.handlePanStart({ clientX: 100, clientY: 200 } as PointerEvent)
await pz.handlePanMove({
clientX: 150,
clientY: 250
} as PointerEvent)
expect(mockStore.setPanOffset).toHaveBeenCalled()
})
it('throws if move called without start', async () => {
const pz = usePanAndZoom()
await expect(
pz.handlePanMove({ clientX: 0, clientY: 0 } as PointerEvent)
).rejects.toThrow('mouseDownPoint is null')
})
})
describe('zoom', () => {
it('zooms in with negative deltaY and updates store', async () => {
const { pz } = await initComposable()
const initialZoom = vi.mocked(mockStore.setZoomRatio).mock.calls[0]?.[0]
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: -100
} as WheelEvent)
const zoomValue = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
expect(zoomValue).toBeGreaterThan(initialZoom ?? 0)
})
it('zooms out with positive deltaY producing smaller zoom', async () => {
const { pz } = await initComposable()
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: -100
} as WheelEvent)
const zoomIn = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
vi.clearAllMocks()
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: 100
} as WheelEvent)
const zoomOut = vi.mocked(mockStore.setZoomRatio).mock.calls[0][0]
expect(zoomOut).toBeLessThan(zoomIn)
})
it('clamps zoom at lower bound after many zoom-outs', async () => {
const { pz } = await initComposable()
for (let i = 0; i < 50; i++) {
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: 100
} as WheelEvent)
}
const calls = mockStore.setZoomRatio.mock.calls
expect(calls[calls.length - 1][0]).toBeGreaterThanOrEqual(0.2)
})
it('clamps zoom at upper bound after many zoom-ins', async () => {
const { pz } = await initComposable()
for (let i = 0; i < 100; i++) {
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: -100
} as WheelEvent)
}
const calls = mockStore.setZoomRatio.mock.calls
expect(calls[calls.length - 1][0]).toBeLessThanOrEqual(10)
})
it('returns early when maskCanvas is null', async () => {
const pz = usePanAndZoom()
const container = createMockElement()
mockStore.canvasContainer = container as unknown as HTMLElement
mockStore.maskCanvas = null
await pz.initializeCanvasPanZoom(
createMockImage(800, 600),
createMockElement()
)
vi.clearAllMocks()
await pz.zoom({
clientX: 400,
clientY: 300,
deltaY: -100
} as WheelEvent)
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
})
it('updates cursor position after zooming', async () => {
const { pz } = await initComposable()
await pz.zoom({
clientX: 300,
clientY: 200,
deltaY: -100
} as WheelEvent)
expect(mockStore.setCursorPoint).toHaveBeenCalled()
})
})
describe('updateCursorPosition', () => {
it('calls store.setCursorPoint', async () => {
const { pz } = await initComposable()
pz.updateCursorPosition({ x: 500, y: 400 })
expect(mockStore.setCursorPoint).toHaveBeenCalled()
})
})
describe('invalidatePanZoom', () => {
it('warns and returns early when image is missing', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
try {
const pz = usePanAndZoom()
await pz.invalidatePanZoom()
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Missing required properties for pan/zoom'
)
} finally {
consoleWarnSpy.mockRestore()
}
})
})
describe('touch handlers', () => {
it('sets brushVisible false on single touch', () => {
const pz = usePanAndZoom()
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 1, y: 2 })))
expect(mockStore.brushVisible).toBe(false)
})
it('ignores touch when pen pointer is active', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 1, y: 2 })))
expect(mockStore.brushVisible).toBe(true)
})
it('triggers undo on two-finger double-tap', () => {
vi.useFakeTimers()
try {
const pz = usePanAndZoom()
const touches = createTouchList({ x: 100, y: 200 }, { x: 300, y: 200 })
pz.handleTouchStart(createTouchEvent(touches))
vi.advanceTimersByTime(100)
pz.handleTouchStart(createTouchEvent(touches))
expect(mockStore.canvasHistory.undo).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('single-touch move pans the canvas', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 100, y: 200 })))
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(createTouchList({ x: 150, y: 250 }))
)
expect(mockStore.setPanOffset).toHaveBeenCalled()
})
it('two-finger move performs pinch zoom', async () => {
const { pz } = await initComposable()
pz.handleTouchStart(
createTouchEvent(
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
)
)
vi.clearAllMocks()
await pz.handleTouchMove(
createTouchEvent(
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
)
)
expect(mockStore.setZoomRatio).toHaveBeenCalled()
})
it('touch move is ignored when pen is active', async () => {
const pz = usePanAndZoom()
pz.addPenPointerId(1)
await pz.handleTouchMove(
createTouchEvent(createTouchList({ x: 100, y: 200 }))
)
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
})
it('handleTouchEnd calls preventDefault', () => {
const pz = usePanAndZoom()
const event = createTouchEvent(createTouchList({ x: 200, y: 300 }))
pz.handleTouchEnd(event)
expect(event.preventDefault).toHaveBeenCalled()
})
})
describe('pen pointer management', () => {
it('blocks touch input while pen is active', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(5)
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
expect(mockStore.brushVisible).toBe(true)
})
it('does not add duplicate ids', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(5)
pz.addPenPointerId(5)
pz.removePenPointerId(5)
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
expect(mockStore.brushVisible).toBe(false)
})
it('re-enables touch after removing pen id', () => {
const pz = usePanAndZoom()
pz.addPenPointerId(5)
pz.removePenPointerId(5)
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
expect(mockStore.brushVisible).toBe(false)
})
it('no-ops when removing non-existent id', () => {
const pz = usePanAndZoom()
pz.removePenPointerId(999)
pz.handleTouchStart(createTouchEvent(createTouchList({ x: 0, y: 0 })))
expect(mockStore.brushVisible).toBe(false)
})
})
})

View File

@@ -1,7 +1,23 @@
import { ref, watch } from 'vue'
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import {
calculateDragPan,
calculateFitView,
calculatePanZoomStyles,
calculateSingleTouchPan,
calculateZoomAroundPoint,
easeOutCubic,
getCursorPoint,
getDistanceBetweenPoints,
getMidpoint,
getWheelZoomFactor,
interpolateView,
isDoubleTap
} from './panZoomUtils'
export function usePanAndZoom() {
const store = useMaskEditorStore()
@@ -35,24 +51,10 @@ export function usePanAndZoom() {
const cursorPoint = ref<Point>({ x: 0, y: 0 })
const penPointerIdList = ref<number[]>([])
const getTouchDistance = (touches: TouchList): number => {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
const getTouchMidpoint = (touches: TouchList): Point => {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
const updateCursorPosition = (clientPoint: Point): void => {
const cursorX = clientPoint.x - pan_offset.value.x
const cursorY = clientPoint.y - pan_offset.value.y
cursorPoint.value = { x: cursorX, y: cursorY }
store.setCursorPoint({ x: cursorX, y: cursorY })
const point = getCursorPoint(clientPoint, pan_offset.value)
cursorPoint.value = point
store.setCursorPoint(point)
}
const handleDoubleTap = (): void => {
@@ -60,7 +62,6 @@ export function usePanAndZoom() {
}
const invalidatePanZoom = async (): Promise<void> => {
// Single validation check upfront
if (
!image.value?.width ||
!image.value?.height ||
@@ -71,8 +72,12 @@ export function usePanAndZoom() {
return
}
const raw_width = image.value.width * zoom_ratio.value
const raw_height = image.value.height * zoom_ratio.value
const styles = calculatePanZoomStyles(
image.value.width,
image.value.height,
zoom_ratio.value,
pan_offset.value
)
if (!canvasContainer.value) {
canvasContainer.value = store.canvasContainer
@@ -80,10 +85,10 @@ export function usePanAndZoom() {
if (!canvasContainer.value) return
Object.assign(canvasContainer.value.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${pan_offset.value.x}px`,
top: `${pan_offset.value.y}px`
width: styles.containerWidth,
height: styles.containerHeight,
left: styles.containerLeft,
top: styles.containerTop
})
if (!rgbCanvas.value) {
@@ -98,8 +103,8 @@ export function usePanAndZoom() {
rgbCanvas.value.height = image.value.height
}
rgbCanvas.value.style.width = `${raw_width}px`
rgbCanvas.value.style.height = `${raw_height}px`
rgbCanvas.value.style.width = styles.containerWidth
rgbCanvas.value.style.height = styles.containerHeight
}
store.setPanOffset(pan_offset.value)
@@ -118,13 +123,11 @@ export function usePanAndZoom() {
throw new Error('mouseDownPoint is null')
}
const deltaX = mouseDownPoint.value.x - event.clientX
const deltaY = mouseDownPoint.value.y - event.clientY
const pan_x = initialPan.value.x - deltaX
const pan_y = initialPan.value.y - deltaY
pan_offset.value = { x: pan_x, y: pan_y }
pan_offset.value = calculateDragPan(
mouseDownPoint.value,
{ x: event.clientX, y: event.clientY },
initialPan.value
)
await invalidatePanZoom()
}
@@ -135,17 +138,22 @@ export function usePanAndZoom() {
return
}
const deltaX = touch.clientX - lastTouchPoint.value.x
const deltaY = touch.clientY - lastTouchPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
pan_offset.value = calculateSingleTouchPan(
lastTouchPoint.value,
{ x: touch.clientX, y: touch.clientY },
pan_offset.value
)
await invalidatePanZoom()
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
}
const touchToPoint = (touch: Touch): Point => ({
x: touch.clientX,
y: touch.clientY
})
const handleTouchStart = (event: TouchEvent): void => {
event.preventDefault()
@@ -155,23 +163,22 @@ export function usePanAndZoom() {
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - lastTwoFingerTap.value
if (tapTimeDiff < DOUBLE_TAP_DELAY) {
if (isDoubleTap(currentTime, lastTwoFingerTap.value, DOUBLE_TAP_DELAY)) {
handleDoubleTap()
lastTwoFingerTap.value = 0
} else {
lastTwoFingerTap.value = currentTime
const p0 = touchToPoint(event.touches[0])
const p1 = touchToPoint(event.touches[1])
isTouchZooming.value = true
lastTouchZoomDistance.value = getTouchDistance(event.touches)
lastTouchMidPoint.value = getTouchMidpoint(event.touches)
lastTouchZoomDistance.value = getDistanceBetweenPoints(p0, p1)
lastTouchMidPoint.value = getMidpoint(p0, p1)
}
} else if (event.touches.length === 1) {
lastTouchPoint.value = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
lastTouchPoint.value = touchToPoint(event.touches[0])
}
}
@@ -183,37 +190,38 @@ export function usePanAndZoom() {
lastTwoFingerTap.value = 0
if (isTouchZooming.value && event.touches.length === 2) {
const newDistance = getTouchDistance(event.touches)
const zoomFactor = newDistance / lastTouchZoomDistance.value
const oldZoom = zoom_ratio.value
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
const p0 = touchToPoint(event.touches[0])
const p1 = touchToPoint(event.touches[1])
const midpoint = getTouchMidpoint(event.touches)
const newDistance = getDistanceBetweenPoints(p0, p1)
const pinchFactor = newDistance / lastTouchZoomDistance.value
const midpoint = getMidpoint(p0, p1)
if (lastTouchMidPoint.value) {
const deltaX = midpoint.x - lastTouchMidPoint.value.x
const deltaY = midpoint.y - lastTouchMidPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
// Apply midpoint drag
const draggedPan: Offset = {
x: pan_offset.value.x + midpoint.x - lastTouchMidPoint.value.x,
y: pan_offset.value.y + midpoint.y - lastTouchMidPoint.value.y
}
if (maskCanvas.value === null) {
if (!maskCanvas.value) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
const focalX = midpoint.x - rect.left
const focalY = midpoint.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += touchX - touchX * scaleFactor
pan_offset.value.y += touchY - touchY * scaleFactor
const result = calculateZoomAroundPoint(
zoom_ratio.value,
pinchFactor,
draggedPan,
focalX,
focalY
)
zoom_ratio.value = result.zoomRatio
pan_offset.value = result.panOffset
await invalidatePanZoom()
lastTouchZoomDistance.value = newDistance
@@ -229,10 +237,7 @@ export function usePanAndZoom() {
const lastTouch = event.touches[0]
if (lastTouch) {
lastTouchPoint.value = {
x: lastTouch.clientX,
y: lastTouch.clientY
}
lastTouchPoint.value = touchToPoint(lastTouch)
} else {
isTouchZooming.value = false
lastTouchMidPoint.value = { x: 0, y: 0 }
@@ -242,31 +247,29 @@ export function usePanAndZoom() {
const zoom = async (event: WheelEvent): Promise<void> => {
const cursorPosition = { x: event.clientX, y: event.clientY }
const oldZoom = zoom_ratio.value
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
if (!maskCanvas.value) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const mouseX = cursorPosition.x - rect.left
const mouseY = cursorPosition.y - rect.top
const focalX = cursorPosition.x - rect.left
const focalY = cursorPosition.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += mouseX - mouseX * scaleFactor
pan_offset.value.y += mouseY - mouseY * scaleFactor
const result = calculateZoomAroundPoint(
zoom_ratio.value,
getWheelZoomFactor(event.deltaY),
pan_offset.value,
focalX,
focalY
)
zoom_ratio.value = result.zoomRatio
pan_offset.value = result.panOffset
await invalidatePanZoom()
const newImageWidth = maskCanvas.value.clientWidth
const zoomRatio = newImageWidth / imageRootWidth.value
interpolatedZoomRatio.value = zoomRatio
@@ -295,40 +298,31 @@ export function usePanAndZoom() {
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth =
rootElement.value.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = rootElement.value.clientHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / image.value.width
const zoomRatioHeight = availableHeight / image.value.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
const aspectRatio = image.value.width / image.value.height
let finalWidth: number
let finalHeight: number
const targetPan = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const fitResult = calculateFitView({
rootWidth: rootElement.value.clientWidth,
rootHeight: rootElement.value.clientHeight,
imageWidth: image.value.width,
imageHeight: image.value.height,
toolPanelWidth,
sidePanelWidth
})
const startTime = performance.now()
const animate = async (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
const eased = easeOutCubic(progress)
zoom_ratio.value = startZoom + (targetZoom - startZoom) * eased
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
const frame = interpolateView(
startZoom,
fitResult.zoomRatio,
startPan,
fitResult.panOffset,
eased
)
zoom_ratio.value = frame.zoomRatio
pan_offset.value = frame.panOffset
await invalidatePanZoom()
@@ -356,38 +350,24 @@ export function usePanAndZoom() {
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth = root.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = root.clientHeight
const zoomRatioWidth = availableWidth / img.width
const zoomRatioHeight = availableHeight / img.height
const aspectRatio = img.width / img.height
let finalWidth: number
let finalHeight: number
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
panOffset.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
panOffset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const fitResult = calculateFitView({
rootWidth: root.clientWidth,
rootHeight: root.clientHeight,
imageWidth: img.width,
imageHeight: img.height,
toolPanelWidth,
sidePanelWidth
})
if (image.value === null) {
image.value = img
}
imageRootWidth.value = finalWidth
imageRootHeight.value = finalHeight
imageRootWidth.value = fitResult.fittedWidth
imageRootHeight.value = fitResult.fittedHeight
zoom_ratio.value = Math.min(zoomRatioWidth, zoomRatioHeight)
pan_offset.value = panOffset
zoom_ratio.value = fitResult.zoomRatio
pan_offset.value = fitResult.panOffset
penPointerIdList.value = []

View File

@@ -359,7 +359,7 @@ describe('usePainter', () => {
expect(result).toBe('')
})
it('returns existing modelValue when not dirty', async () => {
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
@@ -367,20 +367,11 @@ describe('usePainter', () => {
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
// isCanvasEmpty() is true (no strokes drawn), so returns ''
expect(result).toBe('')
})
})
describe('restoreCanvas', () => {
it('builds correct URL from modelValue on mount', () => {
const { modelValue } = mountPainter()
// Before mount, set the modelValue
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
// With empty modelValue, restoreCanvas exits early
expect(modelValue.value).toBe('')
})
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
vi.mocked(api.apiURL).mockClear()
@@ -424,6 +415,27 @@ describe('usePainter', () => {
expect(mockSetPointerCapture).not.toHaveBeenCalled()
})
it('tolerates setPointerCapture throwing for synthetic events', () => {
const { painter } = mountPainter()
const event = new PointerEvent('pointerdown', { button: 0, pointerId: 1 })
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: vi.fn(() => {
throw new DOMException('NotFoundError')
}),
getBoundingClientRect: vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 100
}))
}
})
expect(() => painter.handlePointerDown(event)).not.toThrow()
})
})
describe('handlePointerUp', () => {
@@ -442,5 +454,21 @@ describe('usePainter', () => {
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
})
it('tolerates releasePointerCapture throwing for synthetic events', () => {
const { painter } = mountPainter()
const event = {
button: 0,
pointerId: 1,
target: {
releasePointerCapture: vi.fn(() => {
throw new DOMException('NotFoundError')
})
}
} as unknown as PointerEvent
expect(() => painter.handlePointerUp(event)).not.toThrow()
})
})
})

View File

@@ -525,10 +525,14 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
cacheCanvasRect()
updateCursorPos(e)
startStroke(e)
try {
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
} catch {
// setPointerCapture may throw for synthetic events (e.g. in tests)
}
}
let pendingMoveEvent: PointerEvent | null = null
@@ -558,7 +562,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
cancelAnimationFrame(rafId)
flushPendingStroke()
}
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
try {
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
} catch {
// releasePointerCapture may throw for synthetic events (e.g. in tests)
}
endStroke()
}

View File

@@ -4,6 +4,7 @@ import { nextTick, ref, shallowRef } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -19,6 +20,10 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
createLoad3d: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
@@ -161,6 +166,7 @@ describe('useLoad3d', () => {
Object.assign(this, mockLoad3d)
return this
})
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
mockToastStore = {
addAlert: vi.fn()
@@ -181,7 +187,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(containerRef)
expect(Load3d).toHaveBeenCalledWith(
expect(createLoad3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 512,
@@ -291,7 +297,7 @@ describe('useLoad3d', () => {
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(function () {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
})
@@ -310,7 +316,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(null!)
expect(Load3d).not.toHaveBeenCalled()
expect(createLoad3d).not.toHaveBeenCalled()
})
it('should accept ref as parameter', () => {
@@ -1029,7 +1035,7 @@ describe('useLoad3d', () => {
await composable.initializeLoad3d(containerRef)
// Should not throw and should use defaults
expect(Load3d).toHaveBeenCalled()
expect(createLoad3d).toHaveBeenCalled()
})
it('should handle background image with existing config', async () => {

View File

@@ -5,8 +5,9 @@ import { getActivePinia } from 'pinia'
import { ref, toRaw, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import Load3d from '@/extensions/core/load3d/Load3d'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -111,7 +112,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview.value = true
}
load3d = new Load3d(containerRef, {
load3d = createLoad3d(containerRef, {
width: widthWidget?.value as number | undefined,
height: heightWidget?.value as number | undefined,
// Provide dynamic dimension getter for reactive updates

View File

@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -32,6 +33,10 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/load3d/createLoad3d', () => ({
createLoad3d: vi.fn()
}))
function createMockSceneManager(): Load3d['sceneManager'] {
const mock: Partial<Load3d['sceneManager']> = {
scene: {} as Load3d['sceneManager']['scene'],
@@ -148,6 +153,7 @@ describe('useLoad3dViewer', () => {
vi.mocked(Load3d).mockImplementation(function () {
Object.assign(this, mockLoad3d)
})
vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d)
mockLoad3dService = {
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
@@ -177,7 +183,7 @@ describe('useLoad3dViewer', () => {
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(Load3d).toHaveBeenCalledWith(containerRef, {
expect(createLoad3d).toHaveBeenCalledWith(containerRef, {
width: undefined,
height: undefined,
getDimensions: undefined,
@@ -219,7 +225,7 @@ describe('useLoad3dViewer', () => {
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(function () {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
})

View File

@@ -1,8 +1,9 @@
import { ref, toRaw, watch } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import Load3d from '@/extensions/core/load3d/Load3d'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -314,7 +315,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const hasTargetDimensions = !!(width && height)
load3d = new Load3d(containerRef, {
load3d = createLoad3d(containerRef, {
width: width ? (toRaw(width).value as number) : undefined,
height: height ? (toRaw(height).value as number) : undefined,
getDimensions: hasTargetDimensions
@@ -442,7 +443,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
load3d = createLoad3d(containerRef, {
width: 800,
height: 600,
isViewerMode: true

View File

@@ -0,0 +1,195 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetItem } from './promotionPolicy'
import {
getPromotableWidgets,
isPreviewPseudoWidget,
isRecommendedWidget
} from './promotionPolicy'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
function widgetItem(
nodeType: string,
widgetName: string,
overrides: Partial<IBaseWidget> = {}
): WidgetItem {
const node = { title: nodeType, id: 1, type: nodeType }
const w = fromPartial<IBaseWidget>({
name: widgetName,
computedDisabled: false,
...overrides
})
return [node, w]
}
describe('isPreviewPseudoWidget', () => {
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('isRecommendedWidget', () => {
it('returns true for widgets on recommended node types', () => {
expect(isRecommendedWidget(widgetItem('CLIPTextEncode', 'text'))).toBe(true)
expect(isRecommendedWidget(widgetItem('LoadImage', 'image'))).toBe(true)
expect(isRecommendedWidget(widgetItem('SaveImage', 'filename'))).toBe(true)
expect(isRecommendedWidget(widgetItem('PreviewImage', 'anything'))).toBe(
true
)
})
it('returns true for seed widgets regardless of node type', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'seed'))).toBe(true)
expect(isRecommendedWidget(widgetItem('KSamplerAdvanced', 'seed'))).toBe(
true
)
})
it('returns false for non-recommended node and widget combinations', () => {
expect(isRecommendedWidget(widgetItem('KSampler', 'steps'))).toBe(false)
expect(isRecommendedWidget(widgetItem('VAEDecode', 'samples'))).toBe(false)
})
it('returns false when widget is computedDisabled', () => {
expect(
isRecommendedWidget(
widgetItem('CLIPTextEncode', 'text', { computedDisabled: true })
)
).toBe(false)
expect(
isRecommendedWidget(
widgetItem('KSampler', 'seed', { computedDisabled: true })
)
).toBe(false)
})
})

View File

@@ -0,0 +1,69 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
export type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualCanvasImagePreview(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}

View File

@@ -1,8 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -17,95 +17,12 @@ vi.mock('@/services/litegraphService', () => ({
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
} from './promotionUtils'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
): IBaseWidget {
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
})
})
describe('pruneDisconnected', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -169,63 +86,6 @@ describe('pruneDisconnected', () => {
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,6 +1,15 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
PartialNode,
WidgetItem
} from '@/core/graph/subgraph/promotionPolicy'
import {
getPromotableWidgets,
isPreviewPseudoWidget,
isRecommendedWidget
} from '@/core/graph/subgraph/promotionPolicy'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -18,11 +27,6 @@ import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
@@ -74,25 +78,6 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
return true
return false
}
export function promoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -199,50 +184,6 @@ export function tryToggleWidgetPromotion() {
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
return supportsVirtualCanvasImagePreview(node)
}
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
return {
name: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'IMAGE_PREVIEW',
options: { serialize: false },
serialize: false,
y: 0,
computedDisabled: false
}
}
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
const widgets = [...(node.widgets ?? [])]
const hasCanvasPreviewWidget = widgets.some(
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
)
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
widgets.push(createVirtualCanvasImagePreviewWidget())
}
return widgets
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}

View File

@@ -19,8 +19,9 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
type Matrix = number[][]
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?]
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
@@ -516,6 +517,8 @@ useExtensionService().registerExtension({
const cameraState = result?.[1]
const bgImagePath = result?.[2]
const extrinsics = result?.[3]
const intrinsics = result?.[4]
modelWidget.value = filePath?.replaceAll('\\', '/')
@@ -533,6 +536,27 @@ useExtensionService().registerExtension({
if (bgImagePath) {
load3d.setBackgroundImage(bgImagePath)
}
if (filePath && extrinsics && intrinsics) {
// configure(settings) above triggered loadModel for this
// execution; capture its generation so that if a newer
// execution queues another load before whenLoadIdle resolves,
// we don't apply this execution's matrices on top of that
// newer model.
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraFromMatrices(extrinsics, intrinsics)
})
.catch((error) => {
console.error(
'Failed to apply camera matrices from Preview3D output:',
error
)
})
}
}
}
})

View File

@@ -37,11 +37,6 @@ type SceneManagerStub = {
dispose: ReturnType<typeof vi.fn>
}
type Load3dPrivate = {
setGizmo(model: THREE.Object3D): void
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
}
function makeGizmoStub(): GizmoStub {
return {
setEnabled: vi.fn(),
@@ -97,6 +92,7 @@ function makeInstance() {
controlsManager,
viewHelperManager,
animationManager,
adapterRef: { current: null },
forceRender: vi.fn(),
handleResize: vi.fn()
})
@@ -208,6 +204,29 @@ describe('Load3d', () => {
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('clearModel nulls adapterRef.current so capability queries fall back to defaults', () => {
Object.assign(ctx.load3d, {
adapterRef: { current: { kind: 'splat' } }
})
let adapterDuringModelManagerClear:
| { kind: string; current?: unknown }
| null
| undefined
ctx.modelManager.clearModel.mockImplementation(() => {
adapterDuringModelManagerClear = (
ctx.load3d as unknown as { adapterRef: { current: unknown } }
).adapterRef.current as { kind: string } | null
})
ctx.load3d.clearModel()
expect(adapterDuringModelManagerClear).toEqual({ kind: 'splat' })
expect(
(ctx.load3d as unknown as { adapterRef: { current: unknown } })
.adapterRef.current
).toBeNull()
})
it('toggleCamera updates both controls and gizmo with the active camera', () => {
ctx.load3d.toggleCamera('orthographic')
@@ -222,23 +241,6 @@ describe('Load3d', () => {
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
const model = new THREE.Object3D()
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
})
it('setupCamera (private) forwards size and center to cameraManager', () => {
const size = new THREE.Vector3(1, 2, 3)
const center = new THREE.Vector3(4, 5, 6)
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
})
})
describe('viewport wiring', () => {
@@ -473,7 +475,7 @@ describe('Load3d', () => {
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
const adapter = kind === null ? null : { kind }
Object.assign(ctx.load3d, {
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
adapterRef: { current: adapter }
})
}
@@ -494,6 +496,185 @@ describe('Load3d', () => {
})
})
describe('setCameraFromMatrices', () => {
it('derives the camera pose from extrinsics+intrinsics and applies it via setCameraState + setFOV', () => {
const setCameraState = vi.fn()
const setFOVImpl = vi.fn()
const getCameraState = vi.fn(() => ({
position: new THREE.Vector3(0, 0, 0),
target: new THREE.Vector3(0, 0, 0),
zoom: 1.5,
cameraType: 'orthographic' as const
}))
Object.assign(ctx.load3d, {
setCameraState,
setFOV: setFOVImpl,
cameraManager: { ...ctx.cameraManager, getCameraState }
})
// Identity rotation, zero translation, fy=cy=1 → fovY = 2*atan(1) = 90°.
// OpenCV → three.js flips Y/Z, so position (0,0,0) stays at origin
// and forward (0,0,1) → target (0,0,-1).
const extrinsics = [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
const intrinsics = [
[1, 0, 0],
[0, 1, 1],
[0, 0, 1]
]
ctx.load3d.setCameraFromMatrices(extrinsics, intrinsics)
expect(setCameraState).toHaveBeenCalledOnce()
const stateArg = setCameraState.mock.calls[0][0] as {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: string
}
expect(stateArg.position.x).toBeCloseTo(0)
expect(stateArg.position.y).toBeCloseTo(0)
expect(stateArg.position.z).toBeCloseTo(0)
expect(stateArg.target.x).toBeCloseTo(0)
expect(stateArg.target.y).toBeCloseTo(0)
expect(stateArg.target.z).toBeCloseTo(-1)
// Zoom and cameraType must be preserved from the current state.
expect(stateArg.zoom).toBe(1.5)
expect(stateArg.cameraType).toBe('orthographic')
expect(setFOVImpl).toHaveBeenCalledOnce()
expect(setFOVImpl.mock.calls[0][0]).toBeCloseTo(90)
})
})
describe('whenLoadIdle', () => {
it('resolves immediately when no load is in flight', async () => {
Object.assign(ctx.load3d, { loadingPromise: null })
await expect(ctx.load3d.whenLoadIdle()).resolves.toBeUndefined()
})
it('waits for the current loadingPromise to settle', async () => {
let resolveLoad!: () => void
const p = new Promise<void>((resolve) => {
resolveLoad = resolve
})
Object.assign(ctx.load3d, { loadingPromise: p })
const idle = ctx.load3d.whenLoadIdle()
let settled = false
void idle.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
resolveLoad()
Object.assign(ctx.load3d, { loadingPromise: null })
await idle
expect(settled).toBe(true)
})
it('drains a chained sequence of loads before resolving', async () => {
let resolveFirst!: () => void
const first = new Promise<void>((resolve) => {
resolveFirst = resolve
})
let resolveSecond!: () => void
const second = new Promise<void>((resolve) => {
resolveSecond = resolve
})
Object.assign(ctx.load3d, { loadingPromise: first })
void first.then(() => {
Object.assign(ctx.load3d, { loadingPromise: second })
})
const idle = ctx.load3d.whenLoadIdle()
let settled = false
void idle.then(() => {
settled = true
})
resolveFirst()
await new Promise((r) => setTimeout(r, 0))
expect(settled).toBe(false)
resolveSecond()
Object.assign(ctx.load3d, { loadingPromise: null })
await idle
expect(settled).toBe(true)
})
it('swallows a rejected loadingPromise and continues draining', async () => {
const failing = Promise.reject(new Error('boom'))
failing.catch(() => {})
Object.assign(ctx.load3d, { loadingPromise: failing })
const idle = ctx.load3d.whenLoadIdle()
Object.assign(ctx.load3d, { loadingPromise: null })
await expect(idle).resolves.toBeUndefined()
})
})
describe('currentLoadGeneration', () => {
it('starts at 0', () => {
const fresh = Object.create(Load3d.prototype) as Load3d
Object.assign(fresh, {
_loadGeneration: 0
})
expect(fresh.currentLoadGeneration).toBe(0)
})
it('ticks synchronously on every loadModel call, before any await', async () => {
const internal = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
_loadGeneration: 0,
loadingPromise: null,
_loadModelInternal: internal
})
const baseline = ctx.load3d.currentLoadGeneration
const p1 = ctx.load3d.loadModel('api/view?filename=a.glb')
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 1)
const p2 = ctx.load3d.loadModel('api/view?filename=b.glb')
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 2)
await Promise.all([p1, p2])
})
it('lets a chained whenLoadIdle continuation skip when a newer load was queued in between', async () => {
const internal = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
_loadGeneration: 0,
loadingPromise: null,
_loadModelInternal: internal
})
const aGeneration = ctx.load3d.currentLoadGeneration
const aPromise = ctx.load3d.loadModel('api/view?filename=a.glb')
const aTarget = ctx.load3d.currentLoadGeneration
expect(aTarget).toBe(aGeneration + 1)
const bPromise = ctx.load3d.loadModel('api/view?filename=b.glb')
expect(ctx.load3d.currentLoadGeneration).toBe(aGeneration + 2)
await Promise.all([aPromise, bPromise])
const apply = vi.fn()
if (ctx.load3d.currentLoadGeneration === aTarget) apply()
expect(apply).not.toHaveBeenCalled()
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -1,18 +1,21 @@
import * as THREE from 'three'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { GizmoManager } from './GizmoManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import type { AnimationManager } from './AnimationManager'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
import type { RecordingManager } from './RecordingManager'
import type { SceneManager } from './SceneManager'
import type { SceneModelManager } from './SceneModelManager'
import type { ViewHelperManager } from './ViewHelperManager'
import { computeCameraFromMatrices } from './cameraFromMatrices'
import type {
CameraState,
CaptureResult,
@@ -27,6 +30,23 @@ import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Load3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
animationManager: AnimationManager
gizmoManager: GizmoManager
adapterRef: AdapterRef
}
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
model: THREE.Object3D
@@ -51,6 +71,7 @@ class Load3d {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
@@ -66,6 +87,7 @@ class Load3d {
recordingManager: RecordingManager
animationManager: AnimationManager
gizmoManager: GizmoManager
adapterRef: AdapterRef
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
@@ -80,7 +102,11 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
constructor(
container: Element | HTMLElement,
deps: Load3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -92,90 +118,20 @@ class Load3d {
this.targetAspectRatio = options.width / options.height
}
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.domElement.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
container.appendChild(this.renderer.domElement)
this.eventManager = new EventManager()
this.sceneManager = new SceneManager(
this.renderer,
this.getActiveCamera.bind(this),
this.getControls.bind(this),
this.eventManager
)
this.cameraManager = new CameraManager(this.renderer, this.eventManager)
this.controlsManager = new ControlsManager(
this.renderer,
this.cameraManager.activeCamera,
this.eventManager
)
this.cameraManager.setControls(this.controlsManager.controls)
this.lightingManager = new LightingManager(
this.sceneManager.scene,
this.eventManager
)
this.hdriManager = new HDRIManager(
this.sceneManager.scene,
this.renderer,
this.eventManager
)
this.viewHelperManager = new ViewHelperManager(
this.renderer,
this.getActiveCamera.bind(this),
this.getControls.bind(this),
this.eventManager
)
this.modelManager = new SceneModelManager(
this.sceneManager.scene,
this.renderer,
this.eventManager,
this.getActiveCamera.bind(this),
this.setupCamera.bind(this),
this.setGizmo.bind(this)
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
this.recordingManager = new RecordingManager(
this.sceneManager.scene,
this.renderer,
this.eventManager
)
this.animationManager = new AnimationManager(this.eventManager)
this.gizmoManager = new GizmoManager(
this.sceneManager.scene,
this.renderer,
this.controlsManager.controls,
this.getActiveCamera.bind(this),
() => {
const transform = this.gizmoManager.getTransform()
this.eventManager.emitEvent('gizmoTransformChange', {
...transform,
enabled: this.gizmoManager.isEnabled(),
mode: this.gizmoManager.getMode()
})
}
)
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.hdriManager = deps.hdriManager
this.viewHelperManager = deps.viewHelperManager
this.loaderManager = deps.loaderManager
this.modelManager = deps.modelManager
this.recordingManager = deps.recordingManager
this.animationManager = deps.animationManager
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef
this.sceneManager.init()
this.cameraManager.init()
@@ -334,22 +290,6 @@ class Load3d {
this.renderer.setScissorTest(false)
}
private getActiveCamera(): THREE.Camera {
return this.cameraManager.activeCamera
}
private getControls() {
return this.controlsManager.controls
}
private setGizmo(model: THREE.Object3D): void {
this.gizmoManager.setupForModel(model)
}
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
this.cameraManager.setupForModel(size, center)
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
@@ -525,12 +465,44 @@ class Load3d {
this.forceRender()
}
setCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
): void {
const { position, target, fovYDegrees } = computeCameraFromMatrices(
extrinsics,
intrinsics
)
const current = this.cameraManager.getCameraState()
this.setCameraState({
position: new THREE.Vector3(position[0], position[1], position[2]),
target: new THREE.Vector3(target[0], target[1], target[2]),
zoom: current.zoom,
cameraType: current.cameraType
})
this.setFOV(fovYDegrees)
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
/**
* Monotonic counter that ticks once per loadModel call, **before** any
* await. Callers can capture this immediately after triggering a load and
* later compare against `currentLoadGeneration` to verify their load is
* still the latest one — useful when chaining post-load work
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
* otherwise wait for any newer queued load and apply stale state to it.
*/
get currentLoadGeneration(): number {
return this._loadGeneration
}
async loadModel(url: string, originalFileName?: string): Promise<void> {
this._loadGeneration += 1
if (this.loadingPromise) {
try {
await this.loadingPromise
@@ -541,6 +513,16 @@ class Load3d {
return this.loadingPromise
}
async whenLoadIdle(): Promise<void> {
let last: Promise<void> | null = null
while (this.loadingPromise && this.loadingPromise !== last) {
last = this.loadingPromise
try {
await last
} catch (e) {}
}
}
private async _loadModelInternal(
url: string,
originalFileName?: string
@@ -567,17 +549,22 @@ class Load3d {
}
isSplatModel(): boolean {
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
return this.adapterRef.current?.kind === 'splat'
}
isPlyModel(): boolean {
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
return this.adapterRef.current?.kind === 'pointCloud'
}
getCurrentModelCapabilities(): ModelAdapterCapabilities {
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
}
clearModel(): void {
this.animationManager.dispose()
this.gizmoManager.detach()
this.modelManager.clearModel()
this.adapterRef.current = null
this.forceRender()
}
@@ -812,16 +799,19 @@ class Load3d {
}
public setGizmoEnabled(enabled: boolean): void {
if (enabled && !this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.setEnabled(enabled)
this.forceRender()
}
public setGizmoMode(mode: GizmoMode): void {
if (!this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.setMode(mode)
this.forceRender()
}
public resetGizmoTransform(): void {
if (!this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.reset()
this.forceRender()
}
@@ -831,6 +821,7 @@ class Load3d {
rotation: { x: number; y: number; z: number },
scale?: { x: number; y: number; z: number }
): void {
if (!this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.applyTransform(position, rotation, scale)
this.forceRender()
}
@@ -876,6 +867,7 @@ class Load3d {
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.adapterRef.current = null
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()

View File

@@ -141,10 +141,9 @@ describe('LoaderManager', () => {
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter rejects', async () => {
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
const { lm } = makeLoaderManager()
// Seed with a previously-successful mesh load so we can prove a later
// failed splat load does not leave the splat adapter published.
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -196,10 +195,13 @@ describe('LoaderManager', () => {
}
let adapterDuringClear: ModelAdapter | null | undefined
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
// Prime the loader with an active adapter, then trigger a new load.
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
oldAdapter
const adapterRef = { current: oldAdapter as ModelAdapter | null }
const lm = new LoaderManager(
modelManager,
eventManager,
[oldAdapter],
adapterRef
)
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
() => {
adapterDuringClear = lm.getCurrentAdapter()
@@ -213,6 +215,29 @@ describe('LoaderManager', () => {
expect(adapterDuringClear).toBe(oldAdapter)
})
it('does not let a slow stale load clobber adapterRef after a newer load took over', async () => {
const { lm } = makeLoaderManager()
let resolveSplatLoad!: (model: THREE.Object3D) => void
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
resolveSplatLoad = resolve
})
splatLoad.mockReturnValueOnce(slowSplatLoad)
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
const aPromise = lm.loadModel('api/view?filename=a.splat')
await Promise.resolve()
await lm.loadModel('api/view?filename=b.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
resolveSplatLoad(new THREE.Object3D())
await aPromise
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
})
})
describe('pickAdapter', () => {

View File

@@ -4,13 +4,14 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
import type {
EventManagerInterface,
LoaderManagerInterface,
ModelManagerInterface
} from './interfaces'
/**
@@ -29,21 +30,23 @@ export class LoaderManager implements LoaderManagerInterface {
private readonly modelManager: ModelManagerInterface
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
private readonly adapterRef: AdapterRef
private currentLoadId: number = 0
private _currentAdapter: ModelAdapter | null = null
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[]
adapters?: readonly ModelAdapter[],
adapterRef?: AdapterRef
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
this.adapterRef = adapterRef ?? createAdapterRef()
}
getCurrentAdapter(): ModelAdapter | null {
return this._currentAdapter
return this.adapterRef.current
}
init(): void {}
@@ -57,7 +60,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingStart', null)
this.modelManager.clearModel()
this._currentAdapter = null
this.adapterRef.current = null
this.modelManager.originalURL = url
@@ -83,18 +86,23 @@ export class LoaderManager implements LoaderManagerInterface {
const result = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
// A newer loadModel has superseded us — do not publish our adapter
// and do not setup the model. Whichever load is current owns the
// shared state.
return
}
if (result && result.model) {
this._currentAdapter = result.adapter
if (result) {
// Publish only after the staleness check so a slow older load
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
await this.modelManager.setupModel(result.model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this._currentAdapter = null
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
@@ -135,7 +143,7 @@ export class LoaderManager implements LoaderManagerInterface {
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
@@ -157,6 +165,6 @@ export class LoaderManager implements LoaderManagerInterface {
if (!adapter) return null
const model = await adapter.load(this.createLoadContext(), path, filename)
return { adapter, model }
return model ? { model, adapter } : null
}
}

View File

@@ -64,6 +64,16 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
fitTargetSize: 5
}
/**
* 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 AdapterRef = { current: ModelAdapter | null }
export const createAdapterRef = (): AdapterRef => ({ current: null })
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
@@ -73,6 +83,29 @@ export interface ModelAdapter {
path: string,
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.
* Gaussian splats) implement this; the default path uses
* `Box3.setFromObject` when this is absent.
*/
computeBounds?(model: THREE.Object3D): THREE.Box3 | null
/**
* Optional. Release renderer-owned resources on this model beyond what
* THREE's geometry/material.dispose covers (e.g. sparkjs SplatMesh
* internal GPU/worker state). Called when the model is removed from the
* scene due to a reload or teardown. Missing for adapters whose models
* the default traversal already handles.
*/
disposeModel?(model: THREE.Object3D): void
/**
* Optional. Default camera placement for adapters that opt out of
* fit-to-viewer (capabilities.fitToViewer === false). The size/center
* pair is forwarded to CameraManager.setupForModel as if the model had
* been normalized. Splat geometry is self-sized and uses this to seat
* the camera at a known-good distance.
*/
defaultCameraPose?(): { size: THREE.Vector3; center: THREE.Vector3 }
}
export async function fetchModelData(

View File

@@ -65,10 +65,10 @@ describe('PointCloudModelAdapter', () => {
expect([...adapter.extensions]).toEqual(['ply'])
})
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
it('identifies as pointCloud with material rebuild + fit-to-viewer + lighting + export, gizmo disabled', () => {
const adapter = new PointCloudModelAdapter()
expect(adapter.kind).toBe('pointCloud')
expect(adapter.capabilities.fitToViewer).toBe(false)
expect(adapter.capabilities.fitToViewer).toBe(true)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
expect(adapter.capabilities.gizmoTransform).toBe(false)
expect(adapter.capabilities.lighting).toBe(true)

View File

@@ -10,6 +10,7 @@ import type {
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import type { MaterialMode } from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
@@ -20,7 +21,7 @@ export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
fitToViewer: true,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
@@ -43,7 +44,7 @@ export class PointCloudModelAdapter implements ModelAdapter {
const plyGeometry =
isASCII && getPLYEngine() === 'fastply'
? this.fastPlyLoader.parse(arrayBuffer)
: this.plyLoader.parse(arrayBuffer)
: (this.plyLoader.setPath(path), this.plyLoader.parse(arrayBuffer))
ctx.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
@@ -118,3 +119,43 @@ function buildMeshGroup(
group.add(mesh)
return group
}
export function buildPointCloudForMaterialMode(
originalGeometry: THREE.BufferGeometry,
mode: MaterialMode,
standardMaterial: THREE.MeshStandardMaterial,
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
): THREE.Group {
const geometry = originalGeometry.clone()
const hasVertexColors = geometry.attributes.color !== undefined
const ctx: ModelLoadContext = {
setOriginalModel: () => {},
registerOriginalMaterial: (mesh, material) =>
originalMaterials.set(mesh, material),
standardMaterial,
materialMode: mode
}
if (mode === 'pointCloud') {
return buildPointsGroup(ctx, geometry, hasVertexColors)
}
const group = buildMeshGroup(ctx, geometry, hasVertexColors)
if (mode === 'normal' || mode === 'wireframe') {
const mesh = group.children[0] as THREE.Mesh
mesh.material =
mode === 'normal'
? new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide
})
: new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true
})
}
return group
}

View File

@@ -1,8 +1,10 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import type { EventManagerInterface } from './interfaces'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { SceneModelManager } from './SceneModelManager'
import type { EventManagerInterface } from './interfaces'
function createMockRenderer(): THREE.WebGLRenderer {
return {
@@ -23,6 +25,7 @@ function createManager(
overrides: {
scene?: THREE.Scene
eventManager?: EventManagerInterface
capabilities?: Partial<ModelAdapterCapabilities>
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
@@ -32,6 +35,10 @@ function createManager(
const getActiveCamera = () => camera
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const capabilities: ModelAdapterCapabilities = {
...DEFAULT_MODEL_CAPABILITIES,
...overrides.capabilities
}
const manager = new SceneModelManager(
scene,
@@ -39,7 +46,8 @@ function createManager(
eventManager,
getActiveCamera,
setupCamera,
setupGizmo
setupGizmo,
() => capabilities
)
return {
@@ -53,6 +61,37 @@ function createManager(
}
}
function createManagerWithPose(opts: {
capabilities?: Partial<ModelAdapterCapabilities>
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
}) {
const scene = new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const setupCamera = vi.fn()
const setupGizmo = vi.fn()
const capabilities: ModelAdapterCapabilities = {
...DEFAULT_MODEL_CAPABILITIES,
...opts.capabilities
}
const manager = new SceneModelManager(
scene,
renderer,
eventManager,
() => camera,
setupCamera,
setupGizmo,
() => capabilities,
() => null,
() => {},
() => opts.pose
)
return { manager, scene, setupCamera, setupGizmo }
}
function createMeshModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 })
@@ -190,6 +229,47 @@ describe('SceneModelManager', () => {
'+z'
)
})
it('uses the adapter default pose when fitToViewer is disabled and a pose is provided', async () => {
const pose = {
size: new THREE.Vector3(5, 5, 5),
center: new THREE.Vector3(0, 2.5, 0)
}
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
{
capabilities: { fitToViewer: false },
pose
}
)
const model = createMeshModel()
await manager.setupModel(model)
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalledWith(pose.size, pose.center)
expect(setupGizmo).not.toHaveBeenCalled()
})
it('falls back to the full setup path when fitToViewer is disabled but no default pose is provided', async () => {
const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose(
{
capabilities: { fitToViewer: false },
pose: null
}
)
const model = createMeshModel()
await manager.setupModel(model)
expect(scene.children).toContain(model)
expect(setupCamera).toHaveBeenCalled()
const callArgs = setupCamera.mock.calls[0]
expect(callArgs[0]).toBeInstanceOf(THREE.Vector3)
expect(callArgs[1]).toBeInstanceOf(THREE.Vector3)
expect(setupGizmo).toHaveBeenCalledWith(model)
})
})
describe('setOriginalModel', () => {
@@ -575,29 +655,11 @@ describe('SceneModelManager', () => {
})
})
describe('containsSplatMesh', () => {
it('returns false when no model', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for regular model', async () => {
const { manager } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
expect(manager.containsSplatMesh()).toBe(false)
})
it('returns false for explicit null argument', () => {
const { manager } = createManager()
expect(manager.containsSplatMesh(null)).toBe(false)
})
})
describe('PLY mode switching', () => {
function createPLYManager() {
const ctx = createManager()
const ctx = createManager({
capabilities: { requiresMaterialRebuild: true }
})
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
@@ -655,7 +717,9 @@ describe('SceneModelManager', () => {
})
it('uses vertex colors when available', () => {
const { manager, scene } = createManager()
const { manager, scene } = createManager({
capabilities: { requiresMaterialRebuild: true }
})
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',

View File

@@ -1,12 +1,14 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import {
type EventManagerInterface,
type MaterialMode,
type ModelManagerInterface,
type UpDirection
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
import type {
EventManagerInterface,
MaterialMode,
ModelManagerInterface,
UpDirection
} from './interfaces'
export class SceneModelManager implements ModelManagerInterface {
@@ -39,6 +41,13 @@ export class SceneModelManager implements ModelManagerInterface {
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
private setupGizmo: (model: THREE.Object3D) => void
private getCurrentCapabilities: () => ModelAdapterCapabilities
private getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null
private disposeModelViaAdapter: (model: THREE.Object3D) => void
private getDefaultCameraPose: () => {
size: THREE.Vector3
center: THREE.Vector3
} | null
constructor(
scene: THREE.Scene,
@@ -46,7 +55,16 @@ export class SceneModelManager implements ModelManagerInterface {
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
setupGizmo: (model: THREE.Object3D) => void
setupGizmo: (model: THREE.Object3D) => void,
getCurrentCapabilities: () => ModelAdapterCapabilities = () =>
DEFAULT_MODEL_CAPABILITIES,
getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null = () =>
null,
disposeModelViaAdapter: (model: THREE.Object3D) => void = () => {},
getDefaultCameraPose: () => {
size: THREE.Vector3
center: THREE.Vector3
} | null = () => null
) {
this.scene = scene
this.renderer = renderer
@@ -55,6 +73,10 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupCamera = setupCamera
this.textureLoader = new THREE.TextureLoader()
this.setupGizmo = setupGizmo
this.getCurrentCapabilities = getCurrentCapabilities
this.getBoundsFromAdapter = getBoundsFromAdapter
this.disposeModelViaAdapter = disposeModelViaAdapter
this.getDefaultCameraPose = getDefaultCameraPose
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
@@ -104,23 +126,11 @@ export class SceneModelManager implements ModelManagerInterface {
})
}
private handlePLYModeSwitch(mode: MaterialMode): void {
if (!(this.originalModel instanceof THREE.BufferGeometry)) {
return
}
const plyGeometry = this.originalModel.clone()
const hasVertexColors = plyGeometry.attributes.color !== undefined
// Find and remove ALL MainModel instances by name to ensure deletion
private removeAllMainModelsFromScene(): void {
const oldMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
oldMainModels.push(obj)
}
if (obj.name === 'MainModel') oldMainModels.push(obj)
})
// Remove and dispose all found MainModels
oldMainModels.forEach((oldModel) => {
oldModel.traverse((child) => {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
@@ -132,103 +142,31 @@ export class SceneModelManager implements ModelManagerInterface {
}
}
})
this.disposeModelViaAdapter(oldModel)
this.scene.remove(oldModel)
})
}
private rebuildForMaterialMode(mode: MaterialMode): void {
if (!(this.originalModel instanceof THREE.BufferGeometry)) return
this.removeAllMainModelsFromScene()
this.currentModel = null
let newModel: THREE.Object3D
if (mode === 'pointCloud') {
// Use Points rendering for point cloud mode
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(plyGeometry, pointMaterial)
newModel = new THREE.Group()
newModel.add(points)
} else {
// Use Mesh rendering for other modes
let meshMaterial: THREE.Material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: this.standardMaterial.clone()
if (
!hasVertexColors &&
meshMaterial instanceof THREE.MeshStandardMaterial
) {
meshMaterial.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(plyGeometry, meshMaterial)
this.originalMaterials.set(mesh, meshMaterial)
newModel = new THREE.Group()
newModel.add(mesh)
// Apply the requested material mode
if (mode === 'normal') {
mesh.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide
})
} else if (mode === 'wireframe') {
mesh.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true
})
}
}
// Double check: remove any remaining MainModel before adding new one
const remainingMainModels: THREE.Object3D[] = []
this.scene.traverse((obj) => {
if (obj.name === 'MainModel') {
remainingMainModels.push(obj)
}
})
remainingMainModels.forEach((obj) => this.scene.remove(obj))
this.currentModel = newModel
const newModel = buildPointCloudForMaterialMode(
this.originalModel,
mode,
this.standardMaterial,
this.originalMaterials
)
newModel.name = 'MainModel'
// Setup the new model
if (mode === 'pointCloud') {
this.scene.add(newModel)
} else {
if (mode !== 'pointCloud') {
const box = new THREE.Box3().setFromObject(newModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const targetSize = this.getCurrentCapabilities().fitTargetSize
const scale = targetSize / maxDim
newModel.scale.multiplyScalar(scale)
@@ -237,9 +175,10 @@ export class SceneModelManager implements ModelManagerInterface {
box.getSize(size)
newModel.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(newModel)
}
this.scene.add(newModel)
this.currentModel = newModel
this.eventManager.emitEvent('materialModeChange', mode)
}
@@ -250,9 +189,8 @@ export class SceneModelManager implements ModelManagerInterface {
this.materialMode = mode
// Handle PLY files specially - they need to be recreated for mode switch
if (this.originalModel instanceof THREE.BufferGeometry) {
this.handlePLYModeSwitch(mode)
if (this.getCurrentCapabilities().requiresMaterialRebuild) {
this.rebuildForMaterialMode(mode)
return
}
@@ -399,6 +337,7 @@ export class SceneModelManager implements ModelManagerInterface {
}
}
})
this.disposeModelViaAdapter(obj)
})
this.reset()
@@ -488,19 +427,23 @@ export class SceneModelManager implements ModelManagerInterface {
this.scene.add(this.currentModel)
}
private computeWorldBounds(model: THREE.Object3D): THREE.Box3 {
return (
this.getBoundsFromAdapter(model) ?? new THREE.Box3().setFromObject(model)
)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model
model.name = 'MainModel'
// Check if model is or contains a SplatMesh (3D Gaussian Splatting)
const isSplatModel = this.containsSplatMesh(model)
if (isSplatModel) {
// SplatMesh handles its own rendering, just add to scene
this.scene.add(model)
// Set a default camera distance for splat models
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
return
if (!this.getCurrentCapabilities().fitToViewer) {
const pose = this.getDefaultCameraPose()
if (pose) {
this.scene.add(model)
this.setupCamera(pose.size, pose.center)
return
}
}
this.scene.add(model)
@@ -514,7 +457,7 @@ export class SceneModelManager implements ModelManagerInterface {
}
this.setupModelMaterials(model)
const box = new THREE.Box3().setFromObject(model)
const box = this.computeWorldBounds(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
@@ -524,32 +467,31 @@ export class SceneModelManager implements ModelManagerInterface {
}
fitToViewer(): void {
if (!this.currentModel || this.containsSplatMesh()) return
if (!this.currentModel || !this.getCurrentCapabilities().fitToViewer) return
const model = this.currentModel
// Reset transform to compute from raw geometry (idempotent)
model.scale.set(1, 1, 1)
model.position.set(0, 0, 0)
model.rotation.set(0, 0, 0)
const box = new THREE.Box3().setFromObject(model)
const box = this.computeWorldBounds(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
if (maxDim === 0) return
const targetSize = 5
const targetSize = this.getCurrentCapabilities().fitTargetSize
const scale = targetSize / maxDim
model.scale.set(scale, scale, scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
const scaledBox = this.computeWorldBounds(model)
scaledBox.getCenter(center)
scaledBox.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
model.position.set(-center.x, -scaledBox.min.y, -center.z)
const newBox = new THREE.Box3().setFromObject(model)
const newBox = this.computeWorldBounds(model)
const newSize = newBox.getSize(new THREE.Vector3())
const newCenter = newBox.getCenter(new THREE.Vector3())
@@ -557,17 +499,6 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupGizmo(model)
}
containsSplatMesh(model?: THREE.Object3D | null): boolean {
const target = model ?? this.currentModel
if (!target) return false
if (target instanceof SplatMesh) return true
let found = false
target.traverse((child) => {
if (child instanceof SplatMesh) found = true
})
return found
}
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
this.originalModel = model
}

View File

@@ -1,21 +1,39 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import type { ModelLoadContext } from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const { splatMeshCtor } = vi.hoisted(() => ({
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
}))
const splatMeshSpies = {
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(),
dispose: vi.fn(),
getBoundingBox: vi.fn(
() =>
new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1))
),
updateWorldMatrix: vi.fn()
}
vi.mock('@sparkjsdev/spark', async () => {
const three = await import('three')
return {
SplatMesh: class extends three.Object3D {
initialized = Promise.resolve()
dispose = splatMeshSpies.dispose
getBoundingBox = splatMeshSpies.getBoundingBox
constructor(opts: { fileBytes: ArrayBuffer }) {
super()
splatMeshCtor(opts)
splatMeshSpies.ctor(opts)
}
override updateWorldMatrix(
force: boolean,
updateChildren: boolean
): void {
splatMeshSpies.updateWorldMatrix(force, updateChildren)
super.updateWorldMatrix(force, updateChildren)
}
}
}
@@ -32,7 +50,13 @@ function makeContext(): ModelLoadContext {
describe('SplatModelAdapter', () => {
beforeEach(() => {
splatMeshCtor.mockReset()
splatMeshSpies.ctor.mockClear()
splatMeshSpies.dispose.mockClear()
splatMeshSpies.getBoundingBox.mockClear()
splatMeshSpies.updateWorldMatrix.mockClear()
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
new ArrayBuffer(8)
)
})
it('exposes splat capabilities on the adapter', () => {
@@ -61,7 +85,7 @@ describe('SplatModelAdapter', () => {
'/api/view?',
'scene.splat'
)
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
@@ -69,6 +93,20 @@ describe('SplatModelAdapter', () => {
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
})
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
const result = await new SplatModelAdapter().load(
makeContext(),
'/api/view?',
'scene.splat'
)
const splat = result.children[0]
expect(splat.quaternion.x).toBe(1)
expect(splat.quaternion.y).toBe(0)
expect(splat.quaternion.z).toBe(0)
expect(splat.quaternion.w).toBe(0)
})
it('propagates fetch errors', async () => {
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
new Error('Failed to fetch model: 500')
@@ -79,4 +117,72 @@ describe('SplatModelAdapter', () => {
adapter.load(makeContext(), '/api/view?', 'scene.splat')
).rejects.toThrow('Failed to fetch model: 500')
})
describe('computeBounds', () => {
it('returns the SplatMesh bounding box transformed to world space', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
const splat = group.children[0]
splat.position.set(10, 0, 0)
const bounds = adapter.computeBounds(group)
expect(bounds).toBeInstanceOf(THREE.Box3)
expect(splatMeshSpies.getBoundingBox).toHaveBeenCalledWith(false)
expect(splatMeshSpies.updateWorldMatrix).toHaveBeenCalledWith(true, false)
// Local bbox was [-1,-1,-1]→[1,1,1]; world matrix translates by +10 X
// (with the splat's quaternion applied to the inner mesh).
expect(bounds!.min.x).toBeCloseTo(9)
expect(bounds!.max.x).toBeCloseTo(11)
})
it('returns null when the first child is not a SplatMesh', () => {
const adapter = new SplatModelAdapter()
const group = new THREE.Group()
group.add(new THREE.Mesh())
expect(adapter.computeBounds(group)).toBeNull()
})
})
describe('disposeModel', () => {
it('calls dispose on every SplatMesh in the model tree', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
adapter.disposeModel(group)
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
})
it('is a no-op when the tree has no SplatMesh', () => {
const adapter = new SplatModelAdapter()
const group = new THREE.Group()
group.add(new THREE.Mesh())
expect(() => adapter.disposeModel(group)).not.toThrow()
})
})
describe('defaultCameraPose', () => {
it('returns the (5,5,5) / (0,2.5,0) seat for self-sized splats', () => {
const adapter = new SplatModelAdapter()
const pose = adapter.defaultCameraPose()
expect(pose.size.x).toBe(5)
expect(pose.size.y).toBe(5)
expect(pose.size.z).toBe(5)
expect(pose.center.x).toBe(0)
expect(pose.center.y).toBe(2.5)
expect(pose.center.z).toBe(0)
})
})
})

View File

@@ -12,13 +12,13 @@ export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: false,
gizmoTransform: true,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
fitTargetSize: 20
}
async load(
@@ -29,10 +29,34 @@ export class SplatModelAdapter implements ModelAdapter {
const arrayBuffer = await fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
await splatMesh.initialized
splatMesh.quaternion.set(1, 0, 0, 0)
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
computeBounds(model: THREE.Object3D): THREE.Box3 | null {
const splat = model.children[0]
if (!(splat instanceof SplatMesh)) return null
splat.updateWorldMatrix(true, false)
return splat.getBoundingBox(false).clone().applyMatrix4(splat.matrixWorld)
}
disposeModel(model: THREE.Object3D): void {
model.traverse((child) => {
if (child instanceof SplatMesh) {
child.dispose()
}
})
}
defaultCameraPose(): { size: THREE.Vector3; center: THREE.Vector3 } {
return {
size: new THREE.Vector3(5, 5, 5),
center: new THREE.Vector3(0, 2.5, 0)
}
}
}

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { computeCameraFromMatrices } from './cameraFromMatrices'
const IDENTITY_R = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1]
] as const
function extrinsics(
r: readonly (readonly number[])[],
t: readonly number[]
): number[][] {
return [
[r[0][0], r[0][1], r[0][2], t[0]],
[r[1][0], r[1][1], r[1][2], t[1]],
[r[2][0], r[2][1], r[2][2], t[2]],
[0, 0, 0, 1]
]
}
function intrinsics(
fx: number,
fy: number,
cx: number,
cy: number
): number[][] {
return [
[fx, 0, cx],
[0, fy, cy],
[0, 0, 1]
]
}
function closeTo(received: readonly number[], expected: readonly number[]) {
expect(received.length).toBe(expected.length)
for (let i = 0; i < expected.length; i++) {
expect(received[i]).toBeCloseTo(expected[i], 6)
}
}
describe('computeCameraFromMatrices', () => {
it('places camera at origin when extrinsics are identity', () => {
const result = computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, 0, 0]),
intrinsics(500, 500, 320, 240)
)
closeTo(result.position, [0, 0, 0])
// Identity forward (0,0,1) in OpenCV world -> after 180° rotation about X
// becomes (0,0,-1) in three.js world (camera looks toward -Z, same as
// three.js PerspectiveCamera default).
closeTo(result.target, [0, 0, -1])
})
it('computes position as -R^T * t for a pure-translation extrinsic (Z flipped to three.js)', () => {
// World-to-camera t = (0, 0, -5) means world origin is 5 units behind
// camera in OpenCV frame. -R^T * t = (0, 0, 5) in OpenCV world.
// After world-rotation 180° about X: three.js position = (0, 0, -5).
const result = computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, 0, -5]),
intrinsics(500, 500, 320, 240)
)
closeTo(result.position, [0, 0, -5])
// Target is one step along camera +Z in OpenCV = (0, 0, 6), then Z-flip
// gives three.js target = (0, 0, -6).
closeTo(result.target, [0, 0, -6])
})
it('rotates forward direction using the third row of R', () => {
// R whose third row = (1, 0, 0): camera +Z axis points along world +X
// in OpenCV. X is not flipped by the OpenCV->three.js rotation, so the
// forward ray stays along +X in three.js world.
const r = [
[0, 0, -1],
[0, 1, 0],
[1, 0, 0]
]
const result = computeCameraFromMatrices(
extrinsics(r, [0, 0, 0]),
intrinsics(500, 500, 320, 240)
)
closeTo(result.position, [0, 0, 0])
closeTo(result.target, [1, 0, 0])
})
it('applies Y-flip to convert OpenCV Y-down to three.js Y-up', () => {
// Camera at OpenCV world Y = 3 (below origin in Y-down world).
// After 180° rotation about X: three.js Y = -3 (below in Y-up world).
const result = computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, -3, 0]),
intrinsics(500, 500, 320, 240)
)
closeTo(result.position, [0, -3, 0])
})
it('computes vertical FOV from fy and cy', () => {
// fy = 500, cy = 250 → fov_y = 2 * atan(0.5) ≈ 53.13°
const result = computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, 0, 0]),
intrinsics(500, 500, 320, 250)
)
expect(result.fovYDegrees).toBeCloseTo(53.1301023542, 6)
})
it('throws when extrinsics is not 4x4', () => {
expect(() =>
computeCameraFromMatrices(
[
[1, 0, 0],
[0, 1, 0],
[0, 0, 1]
],
intrinsics(500, 500, 320, 240)
)
).toThrow(/extrinsics/)
})
it('throws when intrinsics is not 3x3', () => {
expect(() =>
computeCameraFromMatrices(extrinsics(IDENTITY_R, [0, 0, 0]), [
[500, 0, 320, 0],
[0, 500, 240, 0],
[0, 0, 1, 0]
])
).toThrow(/intrinsics/)
})
it.each([
['zero', 0],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY]
])(
'throws when fy is %s rather than producing a NaN/Infinite FOV',
(_label, fy) => {
expect(() =>
computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, 0, 0]),
intrinsics(500, fy, 320, 240)
)
).toThrow(/fy/)
}
)
})

View File

@@ -0,0 +1,94 @@
/**
* Compute a three.js camera pose (position, target, vertical FOV) from a
* pair of OpenCV-convention camera matrices as produced by SHARP / COLMAP /
* other SfM pipelines.
*
* Extrinsics: 4x4 world-to-camera matrix E = [R | t; 0 0 0 1]
* - R is the 3x3 rotation block
* - t is the 3x1 translation block (rightmost column, top three rows)
* Intrinsics: 3x3 camera matrix K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
*
* OpenCV convention: X right, Y down, Z forward.
* three.js convention: X right, Y up, Z backward.
*
* Camera position in world space = -R^T * t
* Forward ray in world space = third row of R (camera's +Z axis)
* Vertical FOV (radians) = 2 * atan(cy / fy)
*
* The whole world is rotated 180° around X to align OpenCV Y-down/Z-forward
* with three.js Y-up/Z-back (same rotation applied to splats at load time
* via SplatMesh.quaternion.set(1, 0, 0, 0)). That rotation flips both Y and Z.
*/
type Vec3 = [number, number, number]
interface CameraFromMatricesResult {
position: Vec3
target: Vec3
fovYDegrees: number
}
export function computeCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
): CameraFromMatricesResult {
assertMatrixShape(extrinsics, 4, 4, 'extrinsics')
assertMatrixShape(intrinsics, 3, 3, 'intrinsics')
const r00 = extrinsics[0][0]
const r01 = extrinsics[0][1]
const r02 = extrinsics[0][2]
const r10 = extrinsics[1][0]
const r11 = extrinsics[1][1]
const r12 = extrinsics[1][2]
const r20 = extrinsics[2][0]
const r21 = extrinsics[2][1]
const r22 = extrinsics[2][2]
const tx = extrinsics[0][3]
const ty = extrinsics[1][3]
const tz = extrinsics[2][3]
const posX = -(r00 * tx + r10 * ty + r20 * tz)
const posY = -(r01 * tx + r11 * ty + r21 * tz)
const posZ = -(r02 * tx + r12 * ty + r22 * tz)
const targetX = posX + r20
const targetY = posY + r21
const targetZ = posZ + r22
const fy = intrinsics[1][1]
const cy = intrinsics[1][2]
if (!Number.isFinite(fy) || fy === 0) {
throw new Error(
`intrinsics[1][1] (fy) must be a non-zero finite number, got ${fy}`
)
}
const fovYRad = 2 * Math.atan(cy / fy)
const fovYDegrees = (fovYRad * 180) / Math.PI
return {
position: [posX, -posY, -posZ],
target: [targetX, -targetY, -targetZ],
fovYDegrees
}
}
function assertMatrixShape(
matrix: readonly (readonly number[])[],
rows: number,
cols: number,
name: string
): void {
if (matrix.length !== rows) {
throw new Error(
`${name} must be ${rows}x${cols}, got ${matrix.length} rows`
)
}
for (let i = 0; i < rows; i++) {
if (matrix[i].length !== cols) {
throw new Error(
`${name} row ${i} must have ${cols} columns, got ${matrix[i].length}`
)
}
}
}

View File

@@ -0,0 +1,278 @@
import { describe, expect, it, vi } from 'vitest'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter'
import { createLoad3d } from './createLoad3d'
const { rendererCtor } = vi.hoisted(() => ({
rendererCtor: vi.fn()
}))
vi.mock('three', async () => {
const actual = await vi.importActual<typeof import('three')>('three')
return {
...actual,
WebGLRenderer: class {
domElement = document.createElement('canvas')
autoClear = false
outputColorSpace = ''
constructor(opts: unknown) {
rendererCtor(opts)
}
setSize() {}
setClearColor() {}
}
}
})
vi.mock('./SceneManager', () => ({
SceneManager: class {
scene = { __scene: true }
}
}))
vi.mock('./CameraManager', () => ({
CameraManager: class {
activeCamera = { __camera: true }
setControls = vi.fn()
setupForModel = vi.fn()
}
}))
vi.mock('./ControlsManager', () => ({
ControlsManager: class {
controls = { __controls: true }
}
}))
vi.mock('./LightingManager', () => ({
LightingManager: class {}
}))
vi.mock('./HDRIManager', () => ({
HDRIManager: class {}
}))
vi.mock('./ViewHelperManager', () => ({
ViewHelperManager: class {}
}))
vi.mock('./SceneModelManager', () => ({
SceneModelManager: class {
getCurrentCapabilities: () => unknown
getBoundsFromAdapter: (model: unknown) => unknown
disposeModelViaAdapter: (model: unknown) => unknown
getDefaultCameraPose: () => unknown
constructor(
_scene: unknown,
_renderer: unknown,
_eventManager: unknown,
_getActiveCamera: unknown,
_setupCamera: unknown,
_setupGizmo: unknown,
getCurrentCapabilities: () => unknown,
getBoundsFromAdapter: (model: unknown) => unknown,
disposeModelViaAdapter: (model: unknown) => unknown,
getDefaultCameraPose: () => unknown
) {
this.getCurrentCapabilities = getCurrentCapabilities
this.getBoundsFromAdapter = getBoundsFromAdapter
this.disposeModelViaAdapter = disposeModelViaAdapter
this.getDefaultCameraPose = getDefaultCameraPose
}
}
}))
vi.mock('./LoaderManager', () => ({
LoaderManager: class {
adapterRefArg: unknown
constructor(
_modelManager: unknown,
_eventManager: unknown,
_adapters: unknown,
adapterRef: unknown
) {
this.adapterRefArg = adapterRef
}
}
}))
vi.mock('./RecordingManager', () => ({
RecordingManager: class {}
}))
vi.mock('./AnimationManager', () => ({
AnimationManager: class {}
}))
vi.mock('./GizmoManager', () => ({
GizmoManager: class {
setupForModel = vi.fn()
getTransform = vi.fn(() => ({}))
isEnabled = vi.fn(() => false)
getMode = vi.fn(() => 'translate')
}
}))
vi.mock('./Load3d', () => ({
default: class {
deps: unknown
options: unknown
constructor(_container: unknown, deps: unknown, options: unknown) {
this.deps = deps
this.options = options
}
}
}))
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
type FakeSceneModelManager = {
getCurrentCapabilities: () => unknown
getBoundsFromAdapter: (model: unknown) => unknown
disposeModelViaAdapter: (model: unknown) => void
getDefaultCameraPose: () => unknown
}
type FakeLoad3d = {
deps: {
adapterRef: { current: ModelAdapter | null }
loaderManager: FakeLoaderManager
modelManager: FakeSceneModelManager
}
options: unknown
}
function createContainer(): HTMLElement {
const container = document.createElement('div')
// Stub appendChild — we only care that one was called, not what was attached.
container.appendChild = vi.fn().mockReturnValue(container)
return container
}
function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter {
return {
kind: 'mesh',
extensions: [],
capabilities: DEFAULT_MODEL_CAPABILITIES,
load: vi.fn().mockResolvedValue(null),
...overrides
} satisfies ModelAdapter
}
describe('createLoad3d', () => {
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
rendererCtor.mockClear()
const container = createContainer()
createLoad3d(container)
expect(rendererCtor).toHaveBeenCalledWith({ alpha: true, antialias: true })
expect(container.appendChild).toHaveBeenCalledOnce()
})
it('forwards Load3DOptions to the Load3d constructor', () => {
const container = createContainer()
const options = { width: 640, height: 480, isViewerMode: true }
const instance = createLoad3d(container, options) as unknown as FakeLoad3d
expect(instance.options).toEqual(options)
})
it('shares one AdapterRef between LoaderManager and SceneModelManager lambdas', () => {
const container = createContainer()
const instance = createLoad3d(container) as unknown as FakeLoad3d
const adapterRef = instance.deps.adapterRef
expect(adapterRef.current).toBeNull()
const loaderRef = instance.deps.loaderManager.adapterRefArg
expect(loaderRef).toBe(adapterRef)
})
describe('SceneModelManager capability lambdas (default — no adapter loaded)', () => {
it('getCurrentCapabilities falls back to DEFAULT_MODEL_CAPABILITIES', () => {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
expect(instance.deps.modelManager.getCurrentCapabilities()).toEqual(
DEFAULT_MODEL_CAPABILITIES
)
})
it('getBoundsFromAdapter returns null', () => {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
expect(
instance.deps.modelManager.getBoundsFromAdapter({} as never)
).toBeNull()
})
it('disposeModelViaAdapter is a no-op', () => {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
expect(() =>
instance.deps.modelManager.disposeModelViaAdapter({} as never)
).not.toThrow()
})
it('getDefaultCameraPose returns null', () => {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
expect(instance.deps.modelManager.getDefaultCameraPose()).toBeNull()
})
})
describe('SceneModelManager capability lambdas (after adapter is published)', () => {
function withAdapter(adapter: ModelAdapter) {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
instance.deps.adapterRef.current = adapter
return instance
}
it('getCurrentCapabilities reads the published adapter capabilities', () => {
const caps: ModelAdapterCapabilities = {
...DEFAULT_MODEL_CAPABILITIES,
gizmoTransform: false,
materialModes: []
}
const instance = withAdapter(makeAdapter({ capabilities: caps }))
expect(instance.deps.modelManager.getCurrentCapabilities()).toBe(caps)
})
it('getBoundsFromAdapter delegates to adapter.computeBounds', () => {
const computeBounds = vi.fn().mockReturnValue('bbox-result')
const instance = withAdapter(makeAdapter({ computeBounds }))
const model = { fake: 'model' }
const result = instance.deps.modelManager.getBoundsFromAdapter(
model as never
)
expect(computeBounds).toHaveBeenCalledWith(model)
expect(result).toBe('bbox-result')
})
it('getBoundsFromAdapter returns null when adapter has no computeBounds', () => {
const instance = withAdapter(makeAdapter())
expect(
instance.deps.modelManager.getBoundsFromAdapter({} as never)
).toBeNull()
})
it('disposeModelViaAdapter delegates to adapter.disposeModel', () => {
const disposeModel = vi.fn()
const instance = withAdapter(makeAdapter({ disposeModel }))
const model = { fake: 'model' }
instance.deps.modelManager.disposeModelViaAdapter(model as never)
expect(disposeModel).toHaveBeenCalledWith(model)
})
it('getDefaultCameraPose delegates to adapter.defaultCameraPose', () => {
const pose = { size: { x: 5 }, center: { x: 0 } }
const defaultCameraPose = vi.fn().mockReturnValue(pose)
const instance = withAdapter(makeAdapter({ defaultCameraPose }))
expect(instance.deps.modelManager.getDefaultCameraPose()).toBe(pose)
expect(defaultCameraPose).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,144 @@
import * as THREE from 'three'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { GizmoManager } from './GizmoManager'
import { HDRIManager } from './HDRIManager'
import { LightingManager } from './LightingManager'
import Load3d from './Load3d'
import type { Load3dDeps } from './Load3d'
import { LoaderManager } from './LoaderManager'
import { createAdapterRef, DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import type { Load3DOptions } from './interfaces'
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
renderer.setSize(300, 300)
renderer.setClearColor(0x282828)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
container.appendChild(renderer.domElement)
return renderer
}
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const renderer = createRenderer(container)
const eventManager = new EventManager()
// Shared mutable handle: LoaderManager writes the active adapter on each
// load; SceneModelManager reads it for capability/bounds/dispose lookups
// without depending on construction order.
const adapterRef = createAdapterRef()
let cameraManager: CameraManager
let controlsManager: ControlsManager
let gizmoManager: GizmoManager
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
const getControls = () => controlsManager.controls
const sceneManager = new SceneManager(
renderer,
getActiveCamera,
getControls,
eventManager
)
cameraManager = new CameraManager(renderer, eventManager)
controlsManager = new ControlsManager(
renderer,
cameraManager.activeCamera,
eventManager
)
cameraManager.setControls(controlsManager.controls)
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
const hdriManager = new HDRIManager(
sceneManager.scene,
renderer,
eventManager
)
const viewHelperManager = new ViewHelperManager(
renderer,
getActiveCamera,
getControls,
eventManager
)
const modelManager = new SceneModelManager(
sceneManager.scene,
renderer,
eventManager,
getActiveCamera,
(size, center) => cameraManager.setupForModel(size, center),
(model) => gizmoManager.setupForModel(model),
() => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
(model) => adapterRef.current?.disposeModel?.(model),
() => adapterRef.current?.defaultCameraPose?.() ?? null
)
const loaderManager = new LoaderManager(
modelManager,
eventManager,
undefined,
adapterRef
)
const recordingManager = new RecordingManager(
sceneManager.scene,
renderer,
eventManager
)
const animationManager = new AnimationManager(eventManager)
gizmoManager = new GizmoManager(
sceneManager.scene,
renderer,
controlsManager.controls,
getActiveCamera,
() => {
const transform = gizmoManager.getTransform()
eventManager.emitEvent('gizmoTransformChange', {
...transform,
enabled: gizmoManager.isEnabled(),
mode: gizmoManager.getMode()
})
}
)
return {
renderer,
eventManager,
sceneManager,
cameraManager,
controlsManager,
lightingManager,
hdriManager,
viewHelperManager,
loaderManager,
modelManager,
recordingManager,
animationManager,
gizmoManager,
adapterRef
}
}
export function createLoad3d(
container: Element | HTMLElement,
options?: Load3DOptions
): Load3d {
return new Load3d(container, buildLoad3dDeps(container), options)
}

View File

@@ -107,15 +107,13 @@ useExtensionService().registerExtension({
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
void load3d
.whenLoadIdle()
.then(() => load3d.captureThumbnail(256, 256))
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
}
})

View File

@@ -844,6 +844,15 @@ export class LGraph
return this.elapsed_time
}
/**
* Increments the internal version counter.
* Currently only read for debug display in {@link LGraphCanvas.renderInfo}.
* Centralized so a future VersionSystem can intercept, batch, or replace it.
*/
incrementVersion(): void {
this._version++
}
/**
* @deprecated Will be removed in 0.9
* Sends an event to all the nodes, useful to trigger stuff
@@ -957,7 +966,7 @@ export class LGraph
this.setDirtyCanvas(true)
this.change()
node.graph = this
this._version++
this.incrementVersion()
return
}
@@ -990,7 +999,7 @@ export class LGraph
}
node.graph = this
this._version++
this.incrementVersion()
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
@@ -1043,7 +1052,7 @@ export class LGraph
this._groups.splice(index, 1)
}
node.graph = undefined
this._version++
this.incrementVersion()
this.setDirtyCanvas(true, true)
this.change()
return
@@ -1110,7 +1119,7 @@ export class LGraph
node.onRemoved?.()
node.graph = null
this._version++
this.incrementVersion()
// remove from canvas render
const { list_of_graphcanvas } = this
@@ -2720,7 +2729,7 @@ export class LGraph
this.updateExecutionOrder()
this.onConfigure?.(data)
this._version++
this.incrementVersion()
// Ensure the primary canvas is set to the correct graph
const { primaryCanvas } = this

View File

@@ -2223,6 +2223,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.state.ghostNodeId != null) {
if (e.button === 0) this.finalizeGhostPlacement(false)
if (e.button === 2) this.finalizeGhostPlacement(true)
this.canvas.focus()
e.stopPropagation()
e.preventDefault()
return
@@ -3081,7 +3082,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (oldValue != widget.value) {
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
if (!node.graph) throw new NullGraphError()
node.graph._version++
node.graph.incrementVersion()
}
// Clean up state var
@@ -3679,6 +3680,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
this.state.ghostNodeId = node.id
this.dispatchEvent('litegraph:ghost-placement', {
active: true,
nodeId: node.id
})
this.deselectAll()
this.select(node)
@@ -3709,6 +3714,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.state.ghostNodeId = null
this.isDragging = false
this.dispatchEvent('litegraph:ghost-placement', {
active: false,
nodeId
})
this._autoPan?.stop()
this._autoPan = null
@@ -7888,7 +7897,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
node.properties[property] = value
if (node.graph) {
node.graph._version++
node.graph.incrementVersion()
}
node.onPropertyChanged?.(property, value)
options.onclose?.()

View File

@@ -830,7 +830,7 @@ export class LGraphNode
*/
configure(info: ISerialisedNode): void {
if (this.graph) {
this.graph._version++
this.graph.incrementVersion()
}
if (info.id === -1) info.id = this.id
for (const j in info) {
@@ -2989,7 +2989,7 @@ export class LGraphNode
}
}
}
graph._version++
graph.incrementVersion()
// link has been created now, so its updated
this.onConnectionsChange?.(
@@ -3138,7 +3138,7 @@ export class LGraphNode
// remove the link from the links pool
link_info.disconnect(graph, 'input')
graph._version++
graph.incrementVersion()
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
@@ -3176,7 +3176,7 @@ export class LGraphNode
}
const target = graph.getNodeById(link_info.target_id)
graph._version++
graph.incrementVersion()
if (target) {
const input = target.inputs[link_info.target_slot]
@@ -3304,7 +3304,7 @@ export class LGraphNode
}
link_info.disconnect(graph, keepReroutes ? 'output' : undefined)
if (graph) graph._version++
if (graph) graph.incrementVersion()
this.onConnectionsChange?.(
NodeSlotType.INPUT,
@@ -3539,7 +3539,7 @@ export class LGraphNode
collapse(force?: boolean): void {
if (!this.collapsible && !force) return
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.graph.incrementVersion()
this.flags.collapsed = !this.flags.collapsed
this.setDirtyCanvas(true, true)
}
@@ -3550,7 +3550,7 @@ export class LGraphNode
toggleAdvanced() {
if (!this.hasAdvancedWidgets()) return
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.graph.incrementVersion()
this.showAdvanced = !this.showAdvanced
this.expandToFitContent()
this.setDirtyCanvas(true, true)
@@ -3567,7 +3567,7 @@ export class LGraphNode
pin(v?: boolean): void {
if (!this.graph) throw new NullGraphError()
this.graph._version++
this.graph.incrementVersion()
this.flags.pinned = v ?? !this.flags.pinned
this.resizable = !this.pinned
if (!this.pinned) this.flags.pinned = undefined

View File

@@ -1,7 +1,7 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -53,4 +53,10 @@ export interface LGraphCanvasEventMap {
node: LGraphNode
button: LGraphButton
}
/** Ghost placement mode has started or ended. */
'litegraph:ghost-placement': {
active: boolean
nodeId: NodeId
}
}

View File

@@ -134,7 +134,7 @@ export class SubgraphInput extends SubgraphSlot {
}
}
}
subgraph._version++
subgraph.incrementVersion()
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)

View File

@@ -187,7 +187,7 @@ export class SubgraphInputNode
const subgraphInputIndex = link.origin_slot
link.disconnect(subgraph, 'output')
subgraph._version++
subgraph.incrementVersion()
const subgraphInput = this.slots.at(subgraphInputIndex)
if (!subgraphInput) {

View File

@@ -258,4 +258,71 @@ describe('SubgraphNode multi-instance widget isolation', () => {
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
// it.fails pins the open #10849 SubgraphNode.configure regression on Main;
// drop the marker once the inline-proxyWidgets-state fix lands.
it.fails('falls back to source widget value when proxyWidgets is in legacy 2-tuple shape (regression for #10849)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE = 999
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 801 })
instance.configure({
id: 801,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [LEGACY_NOISE]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.serializeValue?.(instance, 0)).toBe(SOURCE_DEFAULT)
})
it.fails('does not corrupt unbound promoted widgets when widgets_values length mismatches view count (regression for #10849)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const SOURCE_DEFAULT = 42
const LEGACY_NOISE_A = 111
const LEGACY_NOISE_B = 222
const { node } = createNodeWithWidget('TestNode', SOURCE_DEFAULT)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 802 })
instance.configure({
id: 802,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [LEGACY_NOISE_A, LEGACY_NOISE_B]
})
const widget = instance.widgets?.[0]
expect(widget?.value).toBe(SOURCE_DEFAULT)
expect(widget?.value).not.toBe(LEGACY_NOISE_A)
})
})

View File

@@ -99,7 +99,7 @@ export class SubgraphOutput extends SubgraphSlot {
}
}
}
subgraph._version++
subgraph.incrementVersion()
node.onConnectionsChange?.(
NodeSlotType.OUTPUT,

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