Compare commits

...

29 Commits

Author SHA1 Message Date
github-actions
1abde46a7f [automated] Update test expectations 2026-04-16 14:09:37 +00:00
jaeone94
632edb79b2 fix(e2e): use external nextFrame util in KeyboardHelper.altSend
Main refactored KeyboardHelper to use nextFrame(this.page) from
@e2e/fixtures/utils/timing instead of a private method. Our altSend
still called this.nextFrame(), which broke typecheck after the merge.
2026-04-16 22:21:21 +09:00
jaeone94
9b0b67aa5b Merge branch 'main' into refactor/node-footer-inline-clean 2026-04-16 22:08:14 +09:00
jaeone94
1227072d4e refactor: route collapsed dimensions through the normal size/bounds path
Per review: drop the parallel collapsed-size path (getCollapsedSize callback,
collapsedSize ynode field, updateNodeCollapsedSize/getNodeCollapsedSize/
clearNodeCollapsedSize on layoutStore, _cachedVueCollapsedSize instance cache).
ResizeObserver now writes collapsed dimensions to batchUpdateNodeBounds like
any other size change, which flows through useLayoutSync to liteNode.size.

measure() needs one guard: collapsed branch still runs for legacy nodes, but
Vue mode defers to this.size via `|| LiteGraph.vueNodesMode`. Without this,
measure() would hit the canvas-ctx-less fallback and return NODE_COLLAPSED_WIDTH.

Also dropped:
- data-node-body attribute (no longer queried)
- bodyWidth test helper option (no longer used)
- collapsed-node DOM-override branch in boundsUtils (boundingRect is now correct)
2026-04-16 21:55:45 +09:00
jaeone94
a1e6fb36d2 refactor: harden ChangeTracker lifecycle with self-defending API (#10816)
## Summary

Harden the `ChangeTracker` lifecycle to eliminate the class of bugs
where an inactive workflow's tracker silently captures the wrong graph
state. Renames `checkState()` to `captureCanvasState()` with a
self-defending assertion, introduces `deactivate()` and
`prepareForSave()` lifecycle methods, and closes a latent undo-history
corruption bug discovered during code review.

## Background

ComfyUI supports multiple workflows open as tabs, but only one canvas
(`app.rootGraph`) exists at a time. When the user switches tabs, the old
workflow's graph is unloaded and the new one is loaded into this shared
canvas.

The old `checkState()` method serialized `app.rootGraph` into
`activeState` to track changes for undo/redo. It had no awareness of
*which* workflow it belonged to -- if called on an inactive tab's
tracker, it would capture the active tab's graph data and silently
overwrite the inactive workflow's state. This caused permanent data loss
(fixed in PR #10745 with caller-side `isActive` guards).

The caller-side guards were fragile: every new call site had to remember
to add the guard, and forgetting would reintroduce the same silent data
corruption. Additionally, `beforeLoadNewGraph` only called `store()`
(viewport/outputs) without `checkState()`, meaning canvas state could be
stale if a tab switch happened without a preceding mouseup event.

### Before (fragile)

```
saveWorkflow(workflow):
  if (isActive(workflow))              <-- caller must remember this guard
    workflow.changeTracker.checkState()      <-- name implies "read", actually writes
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.store()      <-- only saves viewport, NOT graph state
```

### After (self-defending)

```
saveWorkflow(workflow):
  workflow.changeTracker.prepareForSave()   <-- handles active/inactive internally
  ...

beforeLoadNewGraph():
  activeWorkflow.changeTracker.deactivate() <-- captures graph + viewport together
```

## Changes

- Rename `checkState` to `captureCanvasState` with active-tracker
assertion
- Add `deactivate()` and `prepareForSave()` lifecycle methods
- Fix undo-history corruption: `captureCanvasState()` guarded by
`_restoringState`
- Fix viewport regression during undo: `deactivate()` skips
`captureCanvasState()` during undo/redo but always calls `store()` to
preserve viewport (regression from PR #10247)
- Log inactive tracker warnings unconditionally at warn level (not
DEV-only)
- Deprecated `checkState()` wrapper for extension compatibility
- Rename `checkState` to `captureCanvasState` in
`useWidgetSelectActions` composable
- Add `appModeStore.ts` to manual call sites documentation
- Add `checkState()` deprecation note to architecture docs
- Add 16 unit tests covering all guard conditions, lifecycle methods,
and undo behavior
- Add E2E test: "Undo preserves viewport offset"

## New ChangeTracker Public API

| Method | Caller | Purpose |
|--------|--------|---------|
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots
canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()`
(skipped during undo/redo) + `store()`. Freezes state for tab switch. |
| `prepareForSave()` | Save paths only | Active: `captureCanvasState()`.
Inactive: no-op. |
| `checkState()` | **Deprecated** -- extensions only | Wrapper that
delegates to `captureCanvasState()` with deprecation warning. |
| `store()` | Internal to `deactivate()` | Saves viewport, outputs,
subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs,
subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks as
"clean"). |

## Test plan

- [x] Unit tests: 16 tests covering all guard conditions, state capture,
undo queue behavior
- [x] E2E test: "Undo preserves viewport offset" verifies no viewport
drift on undo
- [x] E2E test: "Prevents captureCanvasState from corrupting workflow
state during tab switch"
- [x] Existing E2E: "Closing an inactive tab with save preserves its own
content"
- [ ] Manual: rapidly switch tabs during undo/redo, verify no viewport
drift
- [ ] Manual: verify extensions calling `checkState()` see deprecation
warning in console
2026-04-16 12:54:12 +00:00
jaeone94
394e36984f fix: re-sync collapsed node slot positions after subgraph fitView (#11240)
## Summary

Fix collapsed node connection links rendering at wrong positions when
entering a subgraph for the first time. `fitView()` (added in #10995)
changes canvas scale/offset, invalidating cached slot positions for
collapsed nodes.

## Changes

- **What**: Schedule `requestSlotLayoutSyncForAllNodes()` on the next
frame after `fitView()` in `restoreViewport()` so collapsed node slot
positions are re-measured against the updated transform. Inner RAF
guarded against mid-frame graph changes.
- **Test coverage**:
- Unit tests in `subgraphNavigationStore.viewport.test.ts` verify the
RAF chain calls `requestSlotLayoutSyncForAllNodes` after `fitView`, and
skip the re-sync when the active graph changes between frames.
- E2E screenshot test (`@screenshot` tag) validates correct link
rendering on first subgraph entry using a new fixture with a
pre-collapsed inner node.

## Review Focus

The nested `requestAnimationFrame` is intentional: the outer RAF runs
`fitView()`, which updates `ds.scale`/`ds.offset` and triggers a CSS
transform update on `TransformPane`. The inner RAF ensures the DOM has
reflowed with the new transform before
`requestSlotLayoutSyncForAllNodes()` measures `getBoundingClientRect()`
on slot elements.

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-16 12:38:01 +00:00
Dante
19fff29204 test: backfill e2e coverage gaps for toolkit widgets, minimap, mask editor, painter (#11183)
## Summary

Backfills missing e2e test coverage identified in the [FixIt
Burndown](https://www.notion.so/comfy-org/FixIt-Burndown-32e6d73d365080609a81cdc9bc884460)
audit. Adds 39 new behavioral tests across 5 spec files with zero
test-code overlap.

## Changes

- **What**: New e2e specs for Image Crop (6 tests) and Curve Widget (6
tests). Deepened coverage for Minimap (+6), Mask Editor (+10), Painter
(+11).
- **New fixtures**: `curve_widget.json`, updated
`image_crop_widget.json`

## Test Inventory

| Spec | New tests | Coverage area |
|---|---|---|
| `imageCrop.spec.ts` | 6 | Empty state, bounding box inputs, ratio
selector/presets, lock toggle, programmatic value update |
| `curveWidget.spec.ts` | 6 | SVG render, click-to-add point,
drag-to-reshape, Ctrl+click remove, interpolation mode switch, min-2
guard |
| `minimap.spec.ts` | +6 | Click-to-pan, drag-to-pan, zoom viewport
shrink, node count changes, workflow reload, pan state reflection |
| `maskEditor.spec.ts` | +10 | Brush drawing, undo/redo, clear, cancel,
invert, Ctrl+Z, tool panel/switching, brush settings, save with mock,
eraser |
| `painter.spec.ts` | +11 | Clear, eraser, control visibility toggle,
brush size slider, stroke width comparison, canvas dimensions,
background color, multi-stroke accumulate, color picker, opacity,
partial erase |

## Review Focus

- Mask editor tests use `.maskEditor_toolPanelContainer` class selectors
— may need test-id hardening later
- Painter slider interaction tests could be flaky if slider layout
changes
- All canvas pixel-count assertions use `expect.poll()` with timeouts
for reliability

## Test plan
- [ ] CI passes all new/modified specs
- [ ] No duplicate coverage with existing tests (verified via grep
before writing)
- [ ] No `waitForTimeout` usage (confirmed)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11183-test-backfill-e2e-coverage-gaps-for-toolkit-widgets-minimap-mask-editor-painter-3416d73d3650819ca33edd1f27b9651a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-16 09:48:13 +00:00
Johnpaul Chiwetelu
b3b895a2a9 refactor(test): use canvasOps.clickEmptySpace in copyPaste spec (#10991)
## Summary

Replace two hardcoded blank-canvas click positions in
`copyPaste.spec.ts` with the existing
`comfyPage.canvasOps.clickEmptySpace()` helper.

## Changes

- **What**: Both `{ x: 50, y: 500 }` click literals in the `Copy paste
node, image paste onto LoadImage, image paste on empty canvas` test now
use `canvasOps.clickEmptySpace()` (which wraps
`DefaultGraphPositions.emptySpaceClick = { x: 35, y: 31 }`). Redundant
`await nextFrame()` calls dropped — the helper already awaits a frame
internally.

## Review Focus

Draft PR — need CI to confirm `(35, 31)` is a valid blank-canvas click
for the `load_image_with_ksampler` workflow used by this test. The
workflow places `LoadImage` at `[50, 50]` and `KSampler` at `[500, 50]`,
so `(35, 31)` should be clear of both. Locally the test was already
failing on `main` (pre-existing, unrelated), so CI is the source of
truth here. If CI fails, the fallback is to add a dedicated named
constant `emptyCanvasClick: { x: 50, y: 500 }` to
`DefaultGraphPositions` as originally proposed in the issue.

Fixes #10330

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10991-refactor-test-use-canvasOps-clickEmptySpace-in-copyPaste-spec-33d6d73d3650817aa3ccea44cb48c0ae)
by [Unito](https://www.unito.io)
2026-04-16 09:44:06 +00:00
Dante
e5c81488e4 fix: include focusMode in splitter refresh key to prevent panel resize (#11295)
## Summary

When the properties panel is open, toggling focus mode on then off
causes the panel to resize unexpectedly. The root cause is that
`splitterRefreshKey` in `LiteGraphCanvasSplitterOverlay.vue` does not
include `focusMode`, so the PrimeVue Splitter component instance is
reused across focus mode transitions and restores stale panel sizes from
localStorage.

Fix: add `focusMode` to `splitterRefreshKey` so the Splitter is
recreated when focus mode toggles.

## Red-Green Verification

| Commit | CI Status | Purpose |
|--------|-----------|---------|
| `test: add failing test for focus mode toggle resizing properties
panel` | 🔴 Red | Proves the test catches the bug |
| `fix: include focusMode in splitter refresh key to prevent panel
resize` | 🟢 Green | Proves the fix resolves the bug |

## demo

### AS IS


https://github.com/user-attachments/assets/95f6a9e3-e4c7-4aba-8e17-0eee11f70491


### TO BE


https://github.com/user-attachments/assets/595eafcd-6a80-443d-a6f3-bb7605ed0758



## Test Plan

- [ ] CI red on test-only commit
- [ ] CI green on fix commit
- [ ] E2E regression test added in
`browser_tests/tests/focusMode.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11295-fix-include-focusMode-in-splitter-refresh-key-to-prevent-panel-resize-3446d73d365081b7bc3ac65338e17a8f)
by [Unito](https://www.unito.io)
2026-04-16 13:43:02 +09:00
Christian Byrne
5c07198acb fix: add validation to E2E coverage shard merge (#11290)
## Summary

Add a validation step after merging E2E coverage shards to detect data
loss and improve observability.

## Changes

- **What**: After `lcov -a` merges shard LCOVs, a new step parses merged
+ per-shard stats (source files, lines hit) and writes them to the
**GitHub Actions job summary** as a markdown table. If merged `LH`
(lines hit) is less than any single shard's `LH`, an error annotation is
emitted — this invariant should never be violated since merging should
only add coverage.
- Helps diagnose the 68% → 42% E2E coverage drop after sharding was
introduced.

## Review Focus

The step is informational — it emits `::error::` annotations but does
not `exit 1`, so it won't block the workflow. We can make it a hard
failure once we're confident the merge is stable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11290-fix-add-validation-to-E2E-coverage-shard-merge-3446d73d365081c8a942e92deba92006)
by [Unito](https://www.unito.io)
2026-04-15 21:39:51 -07:00
Terry Jia
6fb90b224d fix(load3d): restore missed hover state when viewer init is async (#11265)
## Summary
followup https://github.com/Comfy-Org/ComfyUI_frontend/pull/9520
mouseenter fires before load3d is created during async init
(getLoad3dAsync), so the STATUS_MOUSE_ON_VIEWER flag is never set.
This causes isActive() to return false after INITIAL_RENDER_DONE,
stopping the animation loop from calling controlsManager.update() and
making OrbitControls unresponsive on first open.

Track hover state in the composable and sync it to load3d after
creation.
2026-04-15 22:34:57 -04:00
jaeone94
38934fc3b5 refactor: drop NodeLayout.collapsedSize, keep it on the ynode only
collapsedSize was added to NodeLayout earlier in this PR as a first-class
field alongside a special-case tail in yNodeToLayout. Nothing outside of
the mapper actually reads it via the layout shape — measure() goes
through LiteGraph.getCollapsedSize and reads the ynode directly via
layoutStore.getNodeCollapsedSize, bypassing NodeLayout entirely.

With the per-frame cache removed in 2693ec256, there is also no longer
any reason to expose the value on NodeLayout as a feeder for a future
reactive-ref-based cache (Y.Map.get is O(1) and not a bottleneck).

Treat this as undoing an unneeded addition from this PR rather than
introducing new indirection:
- Remove collapsedSize from NodeLayout (types.ts).
- Drop the dead conditional write in layoutToYNode and the
  special-case tail in yNodeToLayout — mappers.ts is now identical to
  main.
- layoutStore.updateNodeCollapsedSize / getNodeCollapsedSize /
  clearNodeCollapsedSize keep writing and reading collapsedSize on
  the ynode via Y.Map's string-keyed API.
2026-04-15 17:31:58 +09:00
jaeone94
4ded224173 test: add regression for collapsed bypass toggle, drive via user actions
Add two E2E tests covering the stale collapsedSize cache bug fixed in
2693ec256: toggling bypass on a collapsed node (which changes body width
via the Bypassed badge) previously left the selection bounding box at
the pre-toggle width.

Switch the existing Vue-mode and legacy-mode selection bounding box
tests from programmatic NodeReference.setCollapsed to the Alt+C
keyboard shortcut. Since the keybinding is mode-agnostic, the same
user-action path now drives both modes and removes the divergence
between DOM-driven Vue tests and programmatic legacy tests.

Add KeyboardHelper.collapse() (Alt+C) next to the existing bypass()
(Ctrl+B) so the shortcut has a discoverable helper. Drop the unused
NodeReference.setCollapsed helper introduced earlier in this PR.
2026-04-15 16:18:07 +09:00
jaeone94
2693ec2560 fix: remove stale collapsedSize cache in LGraphNode.measure()
The _cachedVueCollapsedSize cache was only cleared when a node expanded,
so bypass/mute badge toggles on collapsed nodes (which change body width
but keep flags.collapsed = true) left measure() returning stale bounds.
The ynode update happened correctly; measure() was reading its own cache.

Y.Map.get() is O(1); 60fps × N collapsed nodes is sub-millisecond and
not a measurable bottleneck, so the cache offered no real benefit.

Replace the cache tests with fresh-read + expand/collapse cycle tests
that verify size updates propagate.
2026-04-15 16:03:02 +09:00
jaeone94
31303ad6d7 fix: address agent review feedback
- Fix memory leak: remove element from deferredElements on unmount
- Add nextFrame() after repositionNodes in Vue mode E2E tests
- Guard against zero-size collapsed measurements
- Wrap getCollapsedSize callback in try-catch for Y.Map resilience
- Improve measure() cache test to verify output values after invalidation
- Add layoutStore collapsed size lifecycle unit tests
- Fix LGraphNode.test.ts for updated useVueElementTracking signature
- Use @e2e/ path alias for E2E test imports
2026-04-11 17:18:29 +09:00
jaeone94
803d5856d0 fix: address review feedback on collapsed size, layer violation, and Tailwind scanning
- Move LiteGraph.getCollapsedSize wiring from useVueFeatureFlags (base layer)
  to useVueNodeLifecycle (renderer layer) to fix platform→renderer import
- Replace dynamic Tailwind class interpolation with static lookup maps in
  NodeFooter and LGraphNode to ensure scanner detects all utility classes
- Cache collapsed size on LGraphNode instance to avoid per-frame Y.Map lookups
- Add runtime type guard in getNodeCollapsedSize for CRDT merge safety
- Add collapsed size methods to LayoutStore interface
- Use Vue 3.3+ property syntax for defineEmits in NodeFooter
- Add unit tests for measure() getCollapsedSize branch (5 cases)
2026-04-11 16:43:37 +09:00
jaeone94
86f5067908 fix: address review feedback on collapsed size handling
- Replace data-testid with dedicated data-node-body attribute for
  production collapsed width measurement
- Remove redundant ?? undefined in getCollapsedSize accessor
- Wrap collapsedSize mutations in ydoc.transact() for consistency
  with other ynode mutations
- Add test coverage for inner wrapper width branch, collapsed-to-expand
  lifecycle, and updateNodeCollapsedSize/clearNodeCollapsedSize assertions
2026-04-11 16:43:37 +09:00
jaeone94
1911b843e9 refactor: route collapsed size through layoutStore and measure()
Per review feedback, replace the parallel collapsedSizes Map with a
first-class collapsedSize field stored directly in ynodes. measure()
reads collapsed dimensions via a LiteGraph.getCollapsedSize accessor
injected by the Vue layer, making boundingRect automatically correct.

- Add LiteGraph.getCollapsedSize accessor (dependency injection)
- measure() uses accessor for collapsed nodes when available
- Store collapsedSize in ynode via mappers (layoutToYNode/yNodeToLayout)
- Remove separate collapsedSizes Map and getter spread-merge
- getNodeCollapsedSize reads directly from ynode (no yNodeToLayout)
- clearNodeCollapsedSize on expand to prevent stale data
- Restore selectionBorder.ts to original createBounds (no special path)
- useVueFeatureFlags injects accessor on Vue mode enable
2026-04-11 16:43:37 +09:00
jaeone94
bdfb3ec5ed refactor: address non-blocking review feedback
- Extract footerWrapperBase constant to eliminate third inline duplicate
- Consolidate 3 radius computed into shared getBottomRadius helper
- Narrow useVueElementTracking parameter from MaybeRefOrGetter to string
  (toValue was called eagerly at setup, making reactive updates impossible)
2026-04-11 16:43:36 +09:00
jaeone94
f44c72fd67 fix: restore dragged-node1 screenshot (CI timing fluke) 2026-04-11 16:43:36 +09:00
jaeone94
0dac6e178f fix: address code review for layoutStore.collapsedSize
- Clear collapsedSizes in initializeFromLiteGraph to prevent stale
  entries from previous workflows
- Merge collapsedSize in customRef getter instead of setter to ensure
  persistence across reads
- Use trigger() instead of nodeRef.value assignment in
  updateNodeCollapsedSize
2026-04-11 16:43:36 +09:00
github-actions
2c874d092b [automated] Update test expectations 2026-04-11 16:43:36 +09:00
jaeone94
6456a79491 test: add legacy mode tests and refactor shared assertion
- Add 4 legacy mode selection bounding box tests (expanded/collapsed
  x bottom-left/bottom-right)
- Extract assertSelectionEncompassesNodes shared helper
- Add boundingRect fallback for legacy nodes without DOM elements
- Rename Vue mode test describe for symmetry
2026-04-11 16:43:36 +09:00
jaeone94
706b930e2c refactor: replace onBounding with layoutStore.collapsedSize
- Move min-height to root element (removes footer height accumulation)
- Remove onBounding callback and vueBoundsOverrides Map entirely
- Add collapsedSize field to layoutStore for collapsed node dimensions
- selectionBorder reads collapsedSize for collapsed nodes in Vue mode
- No litegraph core changes, no onBounding usage
2026-04-11 16:43:36 +09:00
jaeone94
065b9a37c1 fix: restore added-node screenshot (CI timing fluke) 2026-04-11 16:43:36 +09:00
github-actions
0087c24ded [automated] Update test expectations 2026-04-11 16:43:36 +09:00
jaeone94
c3f7beea0c fix: invalidate cached measurement on collapse and clarify padding source 2026-04-11 16:43:36 +09:00
jaeone94
7c4b91fca9 refactor: address code review feedback
- Use reactive props destructuring in NodeFooter
- Remove dead isBackground parameter, replace getTabStyles with
  tabStyles constant
- Extract errorWrapperStyles to eliminate 3x class duplication
- Skip vueBoundsOverrides entry when footerHeight is 0
2026-04-11 16:43:35 +09:00
jaeone94
959bd0f830 refactor: inline node footer with isolate -z-1 and onBounding overrides
- Replace absolute overlay footer with inline flow layout
- Use isolate -z-1 on footer wrapper to keep it behind body without
  adding z-index to body (preserving slot stacking freedom)
- Remove footer offset computed classes (footerStateOutlineBottomClass,
  footerRootBorderBottomClass, footerResizeHandleBottomClass, hasFooter)
- Add vueBoundsOverrides Map for DOM-measured footer/collapsed dimensions
- Use onBounding callback to extend boundingRect from vueBoundsOverrides
- Measure body (node-inner-wrapper) for node.size to prevent footer
  height accumulation on Vue/legacy mode switching
- Safe onBounding cleanup (only restore if not wrapped by another)
- Clean up vueBoundsOverrides entries on node unmount
- Add shared test helpers and 8 parameterized E2E tests
2026-04-11 16:43:35 +09:00
59 changed files with 2428 additions and 520 deletions

View File

@@ -54,6 +54,33 @@ jobs:
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
exit 1
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

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

View File

@@ -6,7 +6,7 @@
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"size": [400, 550],
"flags": {},
"order": 0,
"mode": 0,
@@ -27,14 +27,7 @@
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
}
],
"links": [],

View File

@@ -10,6 +10,7 @@ export const DefaultGraphPositions = {
textEncodeNode2: { x: 622, y: 400 },
textEncodeNodeToggler: { x: 430, y: 171 },
emptySpaceClick: { x: 35, y: 31 },
emptyCanvasClick: { x: 50, y: 500 },
// Slot positions
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
@@ -39,6 +40,7 @@ export const DefaultGraphPositions = {
textEncodeNode2: Position
textEncodeNodeToggler: Position
emptySpaceClick: Position
emptyCanvasClick: Position
clipTextEncodeNode1InputSlot: Position
clipTextEncodeNode2InputSlot: Position
clipTextEncodeNode2InputLinkPath: Position

View File

@@ -27,6 +27,15 @@ export class KeyboardHelper {
await nextFrame(this.page)
}
async altSend(
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Alt+${keyToPress}`)
await nextFrame(this.page)
}
async selectAll(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyA', locator)
}
@@ -35,6 +44,10 @@ export class KeyboardHelper {
await this.ctrlSend('KeyB', locator)
}
async collapse(locator?: Locator | null): Promise<void> {
await this.altSend('KeyC', locator)
}
async undo(locator?: Locator | null): Promise<void> {
await this.ctrlSend('KeyZ', locator)
}

View File

@@ -1,7 +1,10 @@
import type { Locator } from '@playwright/test'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -120,6 +123,27 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -202,3 +226,13 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -0,0 +1,99 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
const SELECTION_PADDING = 10
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
// Legacy mode: no Vue DOM element, use boundingRect directly
if (!nodeEl) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (node) {
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -35,6 +35,13 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.waitForFunction(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
const widget = node?.widgets?.find((w) => w.name === 'mask')
return typeof widget?.serializeValue === 'function'
})
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
@@ -50,17 +57,22 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
if (widgetIndex === -1) {
throw new Error('Widget "mask" not found on target node 1.')
}
const widget = node.widgets?.[widgetIndex]
if (!widget) {
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
}
if (typeof widget.serializeValue !== 'function') {
throw new Error(
'mask widget on node 1 does not have a serializeValue function.'
)
}
await widget.serializeValue(node, 0)
await widget.serializeValue(node, widgetIndex)
})
}

View File

@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.checkState()
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
}, value)
}

View File

@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.checkState()
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
})
await expect
.poll(() => comfyPage.page.title())

View File

@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while checkState() is still gated.
// start the next transaction while captureCanvasState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
@@ -272,4 +272,42 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
test('Undo preserves viewport offset', async ({ comfyPage }) => {
// Pan to a distinct offset so we can detect drift
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
const viewportBefore = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
// Make a graph change so we have something to undo
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
// Undo the collapse — viewport should be preserved
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(viewportBefore.scale, 2),
offset: [
expect.closeTo(viewportBefore.offset[0], 0),
expect.closeTo(viewportBefore.offset[1], 0)
]
})
})
})

View File

@@ -12,7 +12,7 @@ test.describe(
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents checkState from corrupting workflow state during tab switch', async ({
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
@@ -21,9 +21,9 @@ test.describe(
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces checkState during graph loading.
// Register an extension that forces captureCanvasState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a checkState call on the wrong graph, corrupting the activeState.
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
@@ -35,7 +35,7 @@ test.describe(
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.checkState()
workflow.changeTracker.captureCanvasState()
}
})
})

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -146,7 +146,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.nextFrame()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
@@ -174,7 +176,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyCanvasClick
})
await comfyPage.nextFrame()
const uploadPromise2 = comfyPage.page.waitForResponse(

View File

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

View File

@@ -0,0 +1,122 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('No input image connected')).toBeVisible()
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
}
)
test(
'Renders bounding box coordinate inputs',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('X')).toBeVisible()
await expect(node.getByText('Y')).toBeVisible()
await expect(node.getByText('Width')).toBeVisible()
await expect(node.getByText('Height')).toBeVisible()
}
)
test(
'Renders ratio selector and lock button',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
}
)
test(
'Lock button toggles aspect ratio lock',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const lockButton = node.getByRole('button', {
name: 'Lock aspect ratio'
})
await expect(lockButton).toBeVisible()
await lockButton.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' })
).toBeVisible()
}
)
test(
'Ratio selector offers expected presets',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const trigger = node.getByRole('combobox')
await trigger.click()
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
for (const label of expectedRatios) {
await expect(
comfyPage.page.getByRole('option', { name: label, exact: true })
).toBeVisible()
}
}
)
test(
'Programmatically setting widget value updates bounding box inputs',
{ tag: '@ui' },
async ({ comfyPage }) => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ bounds: newBounds }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
const inputs = node.locator('input[inputmode="decimal"]')
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
}
)
})

View File

@@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
@@ -27,6 +28,85 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
@@ -52,7 +132,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
}
)
@@ -79,9 +159,245 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await expect(dialog).toHaveScreenshot(
await comfyPage.expectScreenshot(
dialog,
'mask-editor-dialog-from-context-menu.png'
)
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
const invertButton = dialog.getByRole('button', { name: 'Invert' })
await expect(invertButton).toBeVisible()
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
// The tool panel should contain exactly 5 tool entries
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// First tool (MaskPen) should be selected by default
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
}
)
test('switching tools updates the selected indicator', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// Click the third tool (Eraser, index 2)
await toolEntries.nth(2).click()
// The third tool should now be selected
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
// Verify it's the eraser (3rd entry)
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
await expect(thicknessLabel).toBeVisible()
const opacityLabel = dialog.getByText('Opacity').first()
await expect(opacityLabel).toBeVisible()
const hardnessLabel = dialog.getByText('Hardness')
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-image-${imageUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeVisible()
await saveButton.click()
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
// Dialog should remain open when save fails
await expect(dialog).toBeVisible()
})
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)
})

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -16,21 +17,24 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
})
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
function getMinimapLocators(comfyPage: ComfyPage) {
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
return {
container,
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
toggleButton: comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
),
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
}
}
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
function getCanvasOffset(page: Page): Promise<[number, number]> {
return page.evaluate(() => {
const ds = window.app!.canvas.ds
return [ds.offset[0], ds.offset[1]] as [number, number]
})
}
test.describe('Minimap', { tag: '@canvas' }, () => {
@@ -42,23 +46,13 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await expect(canvas).toBeVisible()
await expect(viewport).toBeVisible()
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
await expect(container).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
@@ -69,59 +63,53 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const { container, toggleButton } = getMinimapLocators(comfyPage)
await expect(toggleButton).toBeVisible()
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
const { container, toggleButton } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await toggleButton.click()
await expect(minimapContainer).toBeHidden()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await toggleButton.click()
await expect(minimapContainer).toBeVisible()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const { container } = getMinimapLocators(comfyPage)
await expect(minimapContainer).toBeVisible()
await expect(container).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeHidden()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await comfyPage.page.keyboard.press('Alt+KeyM')
await expect(minimapContainer).toBeVisible()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
await expect(minimap).toBeVisible()
const { container, toggleButton, closeButton } =
getMinimapLocators(comfyPage)
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).toBeHidden()
await expect(container).toBeVisible()
await closeButton.click()
await expect(container).toBeHidden()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
@@ -129,12 +117,10 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
@@ -143,155 +129,192 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
}
)
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
await expect
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
await expect(async () => {
const vb = await viewport.boundingBox()
const mb = await minimap.boundingBox()
expect(vb).toBeTruthy()
expect(mb).toBeTruthy()
expect(vb!.width).toBeGreaterThan(0)
expect(vb!.height).toBeGreaterThan(0)
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
}).toPass({ timeout: 5000 })
const minimapBox = await container.boundingBox()
const viewportBox = await viewport.boundingBox()
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
}
)
test('Clicking on minimap pans the canvas to that position', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const offsetBefore = await getCanvasOffset(comfyPage.page)
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
// Click the top-left quadrant — canvas should pan so that region
// becomes centered, meaning offset increases (moves right/down)
await comfyPage.page.mouse.click(
minimapBox!.x + minimapBox!.width * 0.2,
minimapBox!.y + minimapBox!.height * 0.2
)
await comfyPage.nextFrame()
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Dragging on minimap continuously pans the canvas', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
const startX = minimapBox!.x + minimapBox!.width * 0.3
const startY = minimapBox!.y + minimapBox!.height * 0.3
const endX = minimapBox!.x + minimapBox!.width * 0.7
const endY = minimapBox!.y + minimapBox!.height * 0.7
const offsetBefore = await getCanvasOffset(comfyPage.page)
// Drag from top-left toward bottom-right on the minimap
await comfyPage.page.mouse.move(startX, startY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
// Mid-drag: offset should already differ from initial state
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
expect(
offsetMidDrag[0] !== offsetBefore[0] ||
offsetMidDrag[1] !== offsetBefore[1]
).toBe(true)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Final offset should also differ (drag was not discarded on mouseup)
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Minimap viewport updates when canvas is zoomed', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const viewportBefore = await viewport.boundingBox()
expect(viewportBefore).toBeTruthy()
// Zoom in significantly
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// Viewport rectangle should shrink when zoomed in
await expect
.poll(async () => {
const box = await viewport.boundingBox()
return box?.width ?? 0
})
.toBeLessThan(viewportBefore!.width)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Minimap should have content before deletion
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Remove all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Minimap canvas should be empty — no nodes means nothing to render
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(false)
})
test('Minimap re-renders after loading a different workflow', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Default workflow has content
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Load a very different workflow
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.nextFrame()
// Minimap should still have content (different workflow, still has nodes)
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(true)
})
test('Minimap viewport position reflects canvas pan state', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const positionBefore = await viewport.boundingBox()
expect(positionBefore).toBeTruthy()
// Pan the canvas by a large amount to the right and down
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 500
canvas.ds.offset[1] -= 500
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// The viewport indicator should have moved within the minimap
await expect
.poll(async () => {
const box = await viewport.boundingBox()
if (!box || !positionBefore) return false
return box.x !== positionBefore.x || box.y !== positionBefore.y
})
.toBe(true)
})
})

View File

@@ -370,4 +370,64 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
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')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
})
})
test('Multiple strokes accumulate on the canvas', 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 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)
})
})

View File

@@ -0,0 +1,234 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function userToggleCollapse(
comfyPage: ComfyPage,
nodeRef: NodeReference
) {
await nodeRef.click('title')
await comfyPage.keyboard.collapse()
}
async function userToggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
await comfyPage.keyboard.bypass()
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
}
}
)
test.describe(
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('collapsed node narrows bounding box when bypass is removed', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
test('collapsed node widens bounding box when bypass is added', async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await userToggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await comfyPage.nextFrame()
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const state of nodeStates) {
for (const pos of positions) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await userToggleCollapse(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
})
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
@@ -388,7 +388,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
})
await comfyPage.settings.setSetting(
@@ -419,13 +419,13 @@ test.describe('Workflow Persistence', () => {
.toBe(nodeCountA + 1)
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
// Trigger checkState so isModified is set
// Trigger captureCanvasState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
})
// Switch to A via topbar tab (making B inactive)
@@ -464,7 +464,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
})
await comfyPage.settings.setSetting(
@@ -488,13 +488,13 @@ test.describe('Workflow Persistence', () => {
})
await comfyPage.nextFrame()
// Trigger checkState so isModified is set
// Trigger captureCanvasState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
})
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)

View File

@@ -5,14 +5,18 @@ history by comparing serialized graph snapshots.
## How It Works
`checkState()` is the core method. It:
`captureCanvasState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `checkState()` is explicitly triggered.
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
workflow's tracker. Calling it on an inactive tracker logs a warning and
returns early, preventing cross-workflow data corruption.
## Automatic Triggers
@@ -31,7 +35,7 @@ These are set up once in `ChangeTracker.init()`:
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `checkState()` Manually
## When You Must Call `captureCanvasState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
@@ -50,24 +54,42 @@ rendering. They **do not cover**:
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
```
### Existing Manual Call Sites
These locations already call `checkState()` explicitly:
These locations call `captureCanvasState()` directly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `useAppSetDefaultView.ts` — After setting default view
- `builderViewOptions.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `workflowService.ts` — After workflow service operations
- `appModeStore.ts` — After app mode transitions
`workflowService.ts` calls `captureCanvasState()` indirectly via
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
> for extension compatibility. Extension authors should migrate to
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
## Lifecycle Methods
| Method | Caller | Purpose |
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
## Transaction Guards
@@ -76,7 +98,7 @@ For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls checkState() when nesting count hits 0
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
@@ -84,8 +106,12 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
## Key Invariants
- `checkState()` is a no-op during `loadGraphData` (guarded by
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
inactive trackers get an early return (and a warning log)
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `captureCanvasState()` is a no-op during undo/redo (guarded by
`_restoringState`) to prevent undo history corruption
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

View File

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

View File

@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { checkState: () => void }
changeTracker?: { captureCanvasState: () => void }
} | null>({
isTemporary: true,
initialMode: 'app'

View File

@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
expect(app.rootGraph.extra.linearMode).toBe(false)
})
it('calls changeTracker.checkState', () => {
it('calls changeTracker.captureCanvasState', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
})
it('tracks telemetry with correct default_view', () => {

View File

@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
workflow.changeTracker?.captureCanvasState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})

View File

@@ -31,7 +31,7 @@ function createMockWorkflow(
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
checkState: vi.fn() as Mock
captureCanvasState: vi.fn() as Mock
}
)

View File

@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
canvasStore.canvas?.setDirty(true, true)
currentColorOption.value = canvasColorOption
showColorPicker.value = false
workflowStore.activeWorkflow?.changeTracker.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const currentColorOption = ref<CanvasColorOption | null>(null)

View File

@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
window.requestAnimationFrame(closeDialog)
}

View File

@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.graph?.afterChange()
canvasStore.canvas?.emitAfterChange()
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
return {

View File

@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
groupContext.resizeTo(groupContext.children, padding)
groupContext.graph?.change()
canvasStore.canvas?.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
})
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
})
canvasStore.canvas?.setDirty(true, true)
groupContext.graph?.change()
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
bump()
}
})

View File

@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const toggleNodeCollapse = () => {
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const toggleNodePin = () => {
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const toggleNodeBypass = () => {

View File

@@ -47,7 +47,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const duplicateSelection = () => {
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const deleteSelection = () => {
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
canvas.setDirty(true, true)
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const renameSelection = async () => {
@@ -122,7 +122,7 @@ export function useSelectionOperations() {
const titledItem = item as { title: string }
titledItem.title = newTitle
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
}
return
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
}
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
return
}

View File

@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
canvas.select(node)
canvasStore.updateSelectedItems()
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const doUnpack = (
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
}
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
const unpackSubgraph = () => {

View File

@@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({
}))
const mockChangeTracker = vi.hoisted(() => ({
checkState: vi.fn()
captureCanvasState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
@@ -382,7 +382,7 @@ describe('useCoreCommands', () => {
expect(mockDialogService.prompt).toHaveBeenCalled()
expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description')
expect(mockChangeTracker.checkState).toHaveBeenCalled()
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
})
it('should not set description when user cancels', async () => {
@@ -397,7 +397,7 @@ describe('useCoreCommands', () => {
await setDescCommand.function()
expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
})
})
@@ -432,7 +432,7 @@ describe('useCoreCommands', () => {
'alias2',
'alias3'
])
expect(mockChangeTracker.checkState).toHaveBeenCalled()
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
})
it('should trim whitespace and filter empty strings', async () => {
@@ -478,7 +478,7 @@ describe('useCoreCommands', () => {
await setAliasesCommand.function()
expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
})
})
})

View File

@@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (description === null) return
extra.BlueprintDescription = description.trim() || undefined
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
},
{
@@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
},
{

View File

@@ -430,6 +430,17 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
it('should sync hover state when mouseenter fires before init', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
viewer.handleMouseEnter()
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
})
describe('restoreInitialState', () => {

View File

@@ -86,6 +86,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
let mouseOnViewer = false
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -304,6 +305,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: hasTargetDimensions
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraState = source.getCameraState()
@@ -416,6 +421,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isViewerMode: true
})
if (mouseOnViewer) {
load3d.updateStatusMouseOnViewer(true)
}
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
@@ -522,6 +531,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
mouseOnViewer = true
load3d?.updateStatusMouseOnViewer(true)
}
@@ -529,6 +539,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
mouseOnViewer = false
load3d?.updateStatusMouseOnViewer(false)
}
@@ -727,6 +738,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
mouseOnViewer = false
load3d?.remove()
load3d = null
sourceLoad3d = null

View File

@@ -7,6 +7,7 @@ import type {
Point,
ISerialisedNode
} from '@/lib/litegraph/src/litegraph'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import {
LGraphNode,
LiteGraph,
@@ -653,4 +654,47 @@ describe('LGraphNode', () => {
)
})
})
describe('measure() collapsed branching', () => {
let out: Rect
beforeEach(() => {
out = [0, 0, 0, 0] as unknown as Rect
node.flags.collapsed = true
node.size[0] = 150
node.size[1] = 10
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
test('legacy mode uses NODE_TITLE_HEIGHT-based fallback when no ctx', () => {
LiteGraph.vueNodesMode = false
node.measure(out)
// No ctx → legacy collapsed branch falls back to NODE_COLLAPSED_WIDTH
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode uses this.size directly for collapsed nodes', () => {
LiteGraph.vueNodesMode = true
node.measure(out)
// Vue mode collapsed takes the expanded-style branch
expect(out[2]).toBe(150)
expect(out[3]).toBe(10 + LiteGraph.NODE_TITLE_HEIGHT)
})
test('Vue mode expanded behaves identically to legacy expanded', () => {
LiteGraph.vueNodesMode = true
node.flags.collapsed = false
node.size[0] = 200
node.size[1] = 120
node.measure(out)
expect(out[2]).toBe(200)
expect(out[3]).toBe(120 + LiteGraph.NODE_TITLE_HEIGHT)
})
})
})

View File

@@ -2088,7 +2088,10 @@ export class LGraphNode
out[0] = this.pos[0]
out[1] = this.pos[1] + -titleHeight
if (!this.flags?.collapsed) {
// In Vue mode, `this.size` is kept in sync with the DOM-measured
// collapsed dimensions via ResizeObserver → layoutStore → useLayoutSync,
// so the expanded branch produces correct bounds for collapsed nodes too.
if (!this.flags?.collapsed || LiteGraph.vueNodesMode) {
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {

View File

@@ -140,7 +140,7 @@ export const useWorkflowService = () => {
}
if (isSelfOverwrite) {
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
workflow.changeTracker?.prepareForSave()
await saveWorkflow(workflow)
} else {
let target: ComfyWorkflow
@@ -157,8 +157,7 @@ export const useWorkflowService = () => {
app.rootGraph.extra.linearMode = isApp
target.initialMode = isApp ? 'app' : 'graph'
}
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
target.changeTracker?.prepareForSave()
await workflowStore.saveWorkflow(target)
}
@@ -174,8 +173,7 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
@@ -370,7 +368,7 @@ export const useWorkflowService = () => {
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
activeWorkflow.changeTracker?.deactivate()
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
const activeState = activeWorkflow.activeState
if (activeState) {

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, toValue } from 'vue'
import { computed } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
@@ -176,13 +176,7 @@ describe('LGraphNode', () => {
it('should call resize tracking composable with node ID', () => {
renderLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith(
expect.any(Function),
'node'
)
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
const id = toValue(idArg)
expect(id).toEqual('test-node-123')
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
})
it('should render with data-node-id attribute', () => {

View File

@@ -12,7 +12,9 @@
cn(
'group/node lg-node absolute isolate text-sm',
'flex flex-col contain-layout contain-style',
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
isRerouteNode
? 'h-(--node-height)'
: 'min-h-(--node-height) min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
@@ -55,8 +57,7 @@
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
: 'border-node-stroke-executing'
)
"
/>
@@ -66,8 +67,7 @@
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
hasAnyError ? '-inset-1' : 'inset-0'
)
"
/>
@@ -77,16 +77,12 @@
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'w-(--node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
}
bypassed && bypassOverlayClass,
muted && mutedOverlayClass,
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
)
"
:style="{
@@ -196,7 +192,6 @@
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
@@ -222,8 +217,6 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -271,6 +264,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import {
LGraphCanvas,
LGraphEventMode,
@@ -316,7 +310,6 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
@@ -346,7 +339,7 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
useVueElementTracking(String(nodeData.id), 'node')
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
@@ -566,30 +559,6 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
})
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
@@ -658,14 +627,28 @@ const selectionShapeClass = computed(() => {
}
})
const beforeShapeClass = computed(() => {
const BEFORE_OVERLAY_BASE =
'before:pointer-events-none before:absolute before:inset-0'
const bypassOverlayClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
return `${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl'
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
default:
return 'before:rounded-2xl'
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
}
})
const mutedOverlayClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return BEFORE_OVERLAY_BASE
case RenderShape.CARD:
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE}`
default:
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE}`
}
})

View File

@@ -1,13 +1,16 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<div
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -23,37 +26,38 @@
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
<template
<div
v-else-if="
!isSubgraph &&
hasAnyError &&
showErrorsTabEnabled &&
(showAdvancedInputsButton || showAdvancedState)
"
:class="errorWrapperStyles"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -68,15 +72,15 @@
variant="textonly"
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{
showAdvancedState
? t('rightSidePanel.hideAdvancedShort')
@@ -91,17 +95,20 @@
/>
</div>
</Button>
</template>
</div>
<!-- Case 2: Error Only (Full Width) -->
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<div
v-else-if="hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
tabStyles,
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
footerRadiusClass
)
"
@click.stop="$emit('openErrors')"
@@ -111,18 +118,27 @@
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 3: Subgraph only (Full Width) -->
<template v-else-if="isSubgraph">
<div
v-else-if="isSubgraph"
:class="
cn(
footerWrapperBase,
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
>
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@@ -133,37 +149,47 @@
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
</div>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<Button
<div
v-else-if="showAdvancedInputsButton || showAdvancedState"
variant="textonly"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
footerWrapperBase,
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
<Button
variant="textonly"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</div>
</template>
<script setup lang="ts">
@@ -179,67 +205,67 @@ interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const props = defineProps<Props>()
const {
isSubgraph,
hasAnyError,
showErrorsTabEnabled,
showAdvancedInputsButton,
showAdvancedState,
headerColor,
shape
} = defineProps<Props>()
defineEmits<{
(e: 'enterSubgraph'): void
(e: 'openErrors'): void
(e: 'toggleAdvanced'): void
enterSubgraph: []
openErrors: []
toggleAdvanced: []
}>()
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
// Static lookup to keep class names scannable by Tailwind
const RADIUS_CLASS = {
'rounded-b-17': 'rounded-b-[17px]',
'rounded-b-20': 'rounded-b-[20px]',
'rounded-br-17': 'rounded-br-[17px]',
'rounded-br-20': 'rounded-br-[20px]'
} as const
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
function getBottomRadius(
nodeShape: RenderShape | undefined,
size: '17px' | '20px',
corners: 'both' | 'right' = 'both'
): string {
if (nodeShape === RenderShape.BOX) return ''
const prefix =
nodeShape === RenderShape.CARD || corners === 'right'
? 'rounded-br'
: 'rounded-b'
const key =
`${prefix}-${size === '17px' ? '17' : '20'}` as keyof typeof RADIUS_CLASS
return RADIUS_CLASS[key]
}
const headerColorStyle = computed(() =>
props.headerColor ? { backgroundColor: props.headerColor } : undefined
const footerRadiusClass = computed(() =>
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
const tabStyles = 'pointer-events-auto h-9 text-xs'
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
const errorWrapperStyles = cn(
footerWrapperBase,
'-mx-1 -mb-2 w-[calc(100%+8px)] pb-1'
)
const headerColorStyle = computed(() =>
headerColor ? { backgroundColor: headerColor } : undefined
)
</script>

View File

@@ -47,7 +47,8 @@ const testState = vi.hoisted(() => ({
}))
vi.mock('@vueuse/core', () => ({
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
createSharedComposable: <T>(fn: T) => fn
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -99,6 +100,8 @@ function createResizeEntry(options?: {
if (collapsed) {
element.dataset.collapsed = ''
}
Object.defineProperty(element, 'offsetWidth', { value: width })
Object.defineProperty(element, 'offsetHeight', { value: height })
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
element.getBoundingClientRect = rectSpy
const boxSizes = [{ inlineSize: width, blockSize: height }]
@@ -264,18 +267,56 @@ describe('useVueNodeResizeTracking', () => {
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
})
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
it('writes collapsed dimensions through the normal bounds path', () => {
const nodeId = 'test-node'
const { entry, rectSpy } = createResizeEntry({
const collapsedWidth = 200
const collapsedHeight = 40
const { entry } = createResizeEntry({
nodeId,
width: collapsedWidth,
height: collapsedHeight,
left: 100,
top: 200,
collapsed: true
})
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Seed with larger expanded size so the collapsed write is a real change
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
{
nodeId,
bounds: {
x: 100,
y: 200 + titleHeight,
width: collapsedWidth,
height: collapsedHeight
}
}
])
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
})
it('updates bounds with expanded dimensions on collapse-to-expand transition', () => {
const nodeId = 'test-node'
// Seed with smaller (collapsed) size so expand triggers a real bounds update
seedNodeLayout({ nodeId, left: 100, top: 200, width: 200, height: 10 })
const { entry } = createResizeEntry({
nodeId,
width: 240,
height: 180,
left: 100,
top: 200
})
resizeObserverState.callback?.([entry], createObserverMock())
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
})
})

View File

@@ -8,8 +8,7 @@
* Supports different element types (nodes, slots, widgets, etc.) with
* customizable data attributes and update handlers.
*/
import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
import { useDocumentVisibility } from '@vueuse/core'
@@ -139,25 +138,12 @@ const resizeObserver = new ResizeObserver((entries) => {
const nodeId: NodeId | undefined =
elementType === 'node' ? elementId : undefined
// Skip collapsed nodes — their DOM height is just the header, and writing
// that back to the layout store would overwrite the stored expanded size.
if (elementType === 'node' && element.dataset.collapsed != null) {
if (nodeId) {
nodesNeedingSlotResync.add(nodeId)
}
continue
}
// Measure the full root element (including footer in flow).
// min-height is applied to the root, so footer height in node.size
// does not accumulate on Vue/legacy mode switching.
const width = Math.max(0, element.offsetWidth)
const height = Math.max(0, element.offsetHeight)
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
// Border box is the border included FULL wxh DOM value.
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = Math.max(0, borderBox.inlineSize)
const height = Math.max(0, borderBox.blockSize)
const nodeLayout = nodeId
? layoutStore.getNodeLayoutRef(nodeId).value
: null
@@ -281,10 +267,9 @@ const resizeObserver = new ResizeObserver((entries) => {
* ```
*/
export function useVueElementTracking(
appIdentifierMaybe: MaybeRefOrGetter<string>,
appIdentifier: string,
trackingType: string
) {
const appIdentifier = toValue(appIdentifierMaybe)
onMounted(() => {
const element = getCurrentInstance()?.proxy?.$el
if (!(element instanceof HTMLElement) || !appIdentifier) return
@@ -309,6 +294,7 @@ export function useVueElementTracking(
delete element.dataset[config.dataAttribute]
cachedNodeMeasurements.delete(element)
elementsNeedingFreshMeasurement.delete(element)
deferredElements.delete(element)
resizeObserver.unobserve(element)
})
}

View File

@@ -9,7 +9,7 @@ import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/c
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
@@ -20,7 +20,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
captureCanvasState: mockCaptureCanvasState
}
}
})
@@ -48,7 +48,7 @@ function createItems(...names: string[]): FormDropdownItem[] {
describe('useWidgetSelectActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCheckState.mockClear()
mockCaptureCanvasState.mockClear()
})
describe('updateSelectedItems', () => {
@@ -71,7 +71,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set(['input-1']))
expect(modelValue.value).toBe('photo_abc.jpg')
expect(mockCheckState).toHaveBeenCalledOnce()
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
})
it('clears modelValue when empty set', () => {
@@ -93,7 +93,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set())
expect(modelValue.value).toBeUndefined()
expect(mockCheckState).toHaveBeenCalledOnce()
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
})
})
@@ -130,7 +130,7 @@ describe('useWidgetSelectActions', () => {
await handleFilesUpdate([file])
expect(modelValue.value).toBe('uploaded.png')
expect(mockCheckState).toHaveBeenCalledOnce()
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
})
it('adds uploaded path to widget values array', async () => {

View File

@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function checkWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
function captureWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
function updateSelectedItems(selectedItems: Set<string>) {
@@ -36,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
checkWorkflowState()
captureWorkflowState()
}
async function uploadFile(
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
widget.callback(uploadedPaths[0])
}
checkWorkflowState()
captureWorkflowState()
}
)

View File

@@ -0,0 +1,302 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const mockNodeOutputStore = vi.hoisted(() => ({
snapshotOutputs: vi.fn(() => ({})),
restoreOutputs: vi.fn()
}))
const mockSubgraphNavigationStore = vi.hoisted(() => ({
exportState: vi.fn(() => []),
restoreState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as { changeTracker: unknown } | null,
getWorkflowByPath: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {},
rootGraph: {
serialize: vi.fn(() => ({
nodes: [],
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: 0,
last_link_id: 0
}))
},
canvas: {
ds: { scale: 1, offset: [0, 0] }
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: class {},
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { ChangeTracker } from '@/scripts/changeTracker'
let nodeIdCounter = 0
function createState(nodeCount = 0): ComfyWorkflowJSON {
const nodes = Array.from({ length: nodeCount }, () => ({
id: ++nodeIdCounter,
type: 'TestNode',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
inputs: [],
outputs: [],
properties: {}
}))
return {
nodes,
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: nodeIdCounter,
last_link_id: 0
} as unknown as ComfyWorkflowJSON
}
function createTracker(initialState?: ComfyWorkflowJSON): ChangeTracker {
const state = initialState ?? createState()
const workflow = { path: '/test/workflow.json' } as never
const tracker = new ChangeTracker(workflow, state)
mockWorkflowStore.activeWorkflow = { changeTracker: tracker }
return tracker
}
function mockCanvasState(state: ComfyWorkflowJSON) {
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as never)
}
describe('ChangeTracker', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeIdCounter = 0
ChangeTracker.isLoadingGraph = false
mockWorkflowStore.activeWorkflow = null
mockWorkflowStore.getWorkflowByPath.mockReturnValue(null)
})
describe('captureCanvasState', () => {
describe('guards', () => {
it('is a no-op when app.graph is falsy', () => {
const tracker = createTracker()
const original = tracker.activeState
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(null as never)
tracker.captureCanvasState()
spy.mockRestore()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
it('is a no-op when changeCount > 0', () => {
const tracker = createTracker()
tracker.beforeChange()
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when isLoadingGraph is true', () => {
const tracker = createTracker()
ChangeTracker.isLoadingGraph = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when _restoringState is true', () => {
const tracker = createTracker()
tracker._restoringState = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op and logs error when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
})
describe('state capture', () => {
it('pushes to undoQueue, updates activeState, and calls updateModified', () => {
const initial = createState(1)
const tracker = createTracker(initial)
const changed = createState(2)
mockCanvasState(changed)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.undoQueue[0]).toEqual(initial)
expect(tracker.activeState).toEqual(changed)
expect(api.dispatchCustomEvent).toHaveBeenCalledWith(
'graphChanged',
changed
)
})
it('does not push when state is identical', () => {
const state = createState()
const tracker = createTracker(state)
mockCanvasState(state)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
})
it('clears redoQueue on new change', () => {
const tracker = createTracker(createState(1))
tracker.redoQueue.push(createState(3))
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.redoQueue).toHaveLength(0)
})
it('produces a single undo entry for a beforeChange/afterChange transaction', () => {
const tracker = createTracker(createState(1))
const intermediate = createState(2)
const final = createState(3)
tracker.beforeChange()
mockCanvasState(intermediate)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
mockCanvasState(final)
tracker.afterChange()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.activeState).toEqual(final)
})
it('caps undoQueue at MAX_HISTORY', () => {
const tracker = createTracker(createState(1))
for (let i = 0; i < ChangeTracker.MAX_HISTORY; i++) {
tracker.undoQueue.push(createState(1))
}
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
})
})
})
describe('deactivate', () => {
it('captures canvas state then stores viewport/outputs', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.deactivate()
expect(tracker.activeState).toEqual(changed)
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
expect(mockSubgraphNavigationStore.exportState).toHaveBeenCalled()
})
it('skips captureCanvasState but still calls store during undo/redo', () => {
const tracker = createTracker(createState(1))
tracker._restoringState = true
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
})
it('is a full no-op when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).not.toHaveBeenCalled()
})
})
describe('prepareForSave', () => {
it('captures canvas state when tracker is active', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.prepareForSave()
expect(tracker.activeState).toEqual(changed)
})
it('is a no-op when tracker is inactive', () => {
const tracker = createTracker()
const original = tracker.activeState
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.prepareForSave()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
})
describe('checkState (deprecated)', () => {
it('delegates to captureCanvasState', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.checkState()
expect(tracker.activeState).toEqual(changed)
})
})
})

View File

@@ -4,10 +4,8 @@ import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
@@ -26,14 +24,18 @@ const logger = log.getLogger('ChangeTracker')
// Change to debug for more verbose logging
logger.setLevel('info')
function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
export class ChangeTracker {
static MAX_HISTORY = 50
/**
* Guard flag to prevent checkState from running during loadGraphData.
* Guard flag to prevent captureCanvasState from running during loadGraphData.
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
* contains the NEW workflow's data while activeWorkflow still points to
* the OLD workflow. Any checkState call in that window would serialize
* the wrong graph into the old workflow's activeState, corrupting it.
* the OLD workflow. Any captureCanvasState call in that window would
* serialize the wrong graph into the old workflow's activeState, corrupting it.
*/
static isLoadingGraph = false
/**
@@ -91,6 +93,41 @@ export class ChangeTracker {
this.subgraphState = { navigation }
}
/**
* Freeze this tracker's state before the workflow goes inactive.
* Always calls store() to preserve viewport/outputs. Calls
* captureCanvasState() only when not in undo/redo (to avoid
* corrupting undo history with intermediate graph state).
*
* PRECONDITION: must be called while this workflow is still the active one
* (before the activeWorkflow pointer is moved). If called after the pointer
* has already moved, this is a no-op to avoid freezing wrong viewport data.
*
* @internal Not part of the public extension API.
*/
deactivate() {
if (!isActiveTracker(this)) {
logger.warn(
'deactivate() called on inactive tracker for:',
this.workflow.path
)
return
}
if (!this._restoringState) this.captureCanvasState()
this.store()
}
/**
* Ensure activeState is up-to-date for persistence.
* Active workflow: flushes canvas → activeState.
* Inactive workflow: no-op (activeState was frozen by deactivate()).
*
* @internal Not part of the public extension API.
*/
prepareForSave() {
if (isActiveTracker(this)) this.captureCanvasState()
}
restore() {
if (this.ds) {
app.canvas.ds.scale = this.ds.scale
@@ -138,8 +175,28 @@ export class ChangeTracker {
}
}
checkState() {
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
/**
* Snapshot the current canvas state into activeState and push undo.
* INVARIANT: only the active workflow's tracker may read from the canvas.
* Calling this on an inactive tracker would capture the wrong graph.
*/
captureCanvasState() {
if (
!app.graph ||
this.changeCount ||
this._restoringState ||
ChangeTracker.isLoadingGraph
)
return
if (!isActiveTracker(this)) {
logger.warn(
'captureCanvasState called on inactive tracker for:',
this.workflow.path
)
return
}
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = currentState
@@ -158,6 +215,19 @@ export class ChangeTracker {
}
}
/** @deprecated Use {@link captureCanvasState} instead. */
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
logger.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
this.captureCanvasState()
}
private static _checkStateWarned = false
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {
@@ -216,14 +286,14 @@ export class ChangeTracker {
afterChange() {
if (!--this.changeCount) {
this.checkState()
this.captureCanvasState()
}
}
static init() {
const getCurrentChangeTracker = () =>
useWorkflowStore().activeWorkflow?.changeTracker
const checkState = () => getCurrentChangeTracker()?.checkState()
const captureState = () => getCurrentChangeTracker()?.captureCanvasState()
let keyIgnored = false
window.addEventListener(
@@ -267,8 +337,8 @@ export class ChangeTracker {
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('checkState on keydown')
changeTracker.checkState()
logger.debug('captureCanvasState on keydown')
changeTracker.captureCanvasState()
})
},
true
@@ -277,34 +347,34 @@ export class ChangeTracker {
window.addEventListener('keyup', () => {
if (keyIgnored) {
keyIgnored = false
logger.debug('checkState on keyup')
checkState()
logger.debug('captureCanvasState on keyup')
captureState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
logger.debug('checkState on mouseup')
checkState()
logger.debug('captureCanvasState on mouseup')
captureState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
logger.debug('checkState on promptQueued')
checkState()
logger.debug('captureCanvasState on promptQueued')
captureState()
})
api.addEventListener('graphCleared', () => {
logger.debug('checkState on graphCleared')
checkState()
logger.debug('captureCanvasState on graphCleared')
captureState()
})
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
logger.debug('checkState on processMouseUp')
checkState()
logger.debug('captureCanvasState on processMouseUp')
captureState()
return v
}
@@ -318,9 +388,9 @@ export class ChangeTracker {
) {
const extendedCallback = (v: string) => {
callback(v)
checkState()
captureState()
}
logger.debug('checkState on prompt')
logger.debug('captureCanvasState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -328,8 +398,8 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
logger.debug('checkState on contextMenuClose')
checkState()
logger.debug('captureCanvasState on contextMenuClose')
captureState()
return v
}
@@ -381,7 +451,7 @@ export class ChangeTracker {
const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) {
const listener = () => {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.()
htmlElement.removeEventListener(evt, listener)
}
htmlElement.addEventListener(evt, listener)

View File

@@ -364,29 +364,29 @@ describe('appModeStore', () => {
})
})
it('calls checkState when input is selected', async () => {
it('calls captureCanvasState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
})
it('calls checkState when input is deselected', async () => {
it('calls captureCanvasState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
store.selectedInputs.splice(0, 1)
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
})
it('reflects input changes in linearData', async () => {

View File

@@ -93,7 +93,7 @@ export const useAppModeStore = defineStore('appMode', () => {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.checkState()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
},
{ deep: true }
)

View File

@@ -9,6 +9,7 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
@@ -143,6 +144,12 @@ export const useSubgraphNavigationStore = defineStore(
if (getActiveGraphId() !== graphId) return
if (!canvas.graph?.nodes?.length) return
useLitegraphService().fitView()
// fitView changes scale/offset, so re-sync slot positions for
// collapsed nodes whose DOM-relative measurement is now stale.
requestAnimationFrame(() => {
if (getActiveGraphId() !== graphId) return
requestSlotLayoutSyncForAllNodes()
})
})
}

View File

@@ -12,10 +12,13 @@ import {
VIEWPORT_CACHE_MAX_SIZE
} from '@/stores/subgraphNavigationStore'
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn()
}))
const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn(),
mockRequestSlotSyncAll: vi.fn()
})
)
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -66,6 +69,13 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: mockFitView })
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
requestSlotLayoutSyncForAllNodes: mockRequestSlotSyncAll
})
)
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []
@@ -86,6 +96,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
mockCanvas.ds.state.offset = [0, 0]
mockSetDirty.mockClear()
mockFitView.mockClear()
mockRequestSlotSyncAll.mockClear()
})
afterEach(() => {
@@ -217,6 +228,53 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).not.toHaveBeenCalled()
})
it('re-syncs all slot layouts on the frame after fitView', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
expect(rafCallbacks).toHaveLength(1)
// Outer RAF runs fitView and schedules the inner RAF
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(2)
// Inner RAF re-syncs slots after fitView's transform has been applied
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).toHaveBeenCalledOnce()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips slot re-sync if active graph changed between fitView and inner RAF', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips fitView if active graph changed before rAF fires', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')

View File

@@ -256,7 +256,10 @@ export function createMockChangeTracker(
undoQueue: [],
redoQueue: [],
changeCount: 0,
captureCanvasState: vi.fn(),
checkState: vi.fn(),
deactivate: vi.fn(),
prepareForSave: vi.fn(),
reset: vi.fn(),
restore: vi.fn(),
store: vi.fn(),