Compare commits

...

53 Commits

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

Automatically created by backport workflow.

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

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

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

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

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

---

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

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

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

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

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

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

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

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

---------

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

Automatically created by backport workflow.

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

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

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

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Cherry-picked squash merge commit
`04918360ebcf69adffdf5d8ff6603c24c7a2809a`.

## Why

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

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

## Conflict resolution

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

## Validation

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

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

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

Automatically created by backport workflow.

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

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

---

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

---

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

Cherry-picked from upstream merge commit `b23283144`.

## Why

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

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

## Conflict resolution

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

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

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

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

## Validation

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

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

Original PR: #11541
Original commit: `b23283144`

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

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

---

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

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

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

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

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

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

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

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

Cherry-picked merge commit `46ba65e25cbfbd8214aec8b61951b77aa2db19e5`.

## Conflict resolution

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

## Validation

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

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

Cherry-picked merge commit `b157182a203671f4ea3bfa45ee8c30ae6b725676`.

## Conflict resolution

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

## Validation

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

---------

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

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

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

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

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

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

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

Cherry-picked merge commit `bd96bdf4cc7a8c1460ca5d37cc2c856f4f1654b9`.

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

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

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

## Changes

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

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

## Conflict Resolution

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

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

---------

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

## Conflicts resolved

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

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

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

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

## Verification

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

## Original PR summary

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

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

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-09 23:17:44 +00:00
Christian Byrne
c671a33182 fix(ci): resolve pnpm version conflict in version bump workflow (#10972)
## Summary

Removes hardcoded `version: 10` from `pnpm/action-setup` and instead
injects the `packageManager` field into `package.json` when absent
(legacy `core/*` branches).

## Why

PR #10952 re-added `version: 10` to fix old branches lacking
`packageManager`. But `main` now has **both** `version: 10` (workflow)
and `packageManager: pnpm@10.33.0` (`package.json`), causing
`pnpm/action-setup` to error with:

> Multiple versions of pnpm specified

Failed run:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24158869559

This fix handles both cases:
- **`main`**: has `packageManager` → action reads it directly, no
conflict
- **`core/1.42` etc**: missing `packageManager` → step injects it before
the action runs

E2E test not applicable — this is a CI workflow configuration change
with no user-facing behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10972-fix-ci-resolve-pnpm-version-conflict-in-version-bump-workflow-33c6d73d36508112802df75c0dd5ea50)
by [Unito](https://www.unito.io)
2026-04-08 15:13:33 -07:00
Alexander Brown
25d1ac7456 test: reorganize subgraph test suite into composable domain specs (#10759)
## Summary

Reorganize the subgraph test suite so browser tests are thin
representative user journeys while lower-level Vitest suites own
combinatorics, migration edge cases, and data-shape semantics.

## Changes

- **What**: Migrate 17 flat subgraph browser specs into 10
domain-organized specs under `browser_tests/tests/subgraph/`, move
redundant semantic coverage down to 8 Vitest owner suites, delete all
legacy flat files
- **Browser specs** (54 tests): `subgraphSlots`, `subgraphPromotion`,
`subgraphPromotionDom`, `subgraphSerialization`, `subgraphNavigation`,
`subgraphNested`, `subgraphLifecycle`, `subgraphCrud`, `subgraphSearch`,
`subgraphOperations`
- **Vitest owners** (230 tests): `SubgraphNode.test.ts` (rename/label
propagation), `subgraphNodePromotion.test.ts`,
`promotedWidgetView.test.ts`, `SubgraphSerialization.test.ts`
(duplicate-ID remap), `SubgraphWidgetPromotion.test.ts` (legacy
hydration), `subgraphNavigationStore*.test.ts` (viewport cache,
workflow-switch), `subgraphStore.test.ts` (search aliases, description)
- **Net effect**: browser suite shrinks from ~96 scattered tests to 54
focused journeys

## Review Focus

- Coverage ownership split: each browser test has a unique UI-only
failure mode; semantic coverage lives in Vitest
- `subgraphPromotionDom.spec.ts` forces LiteGraph mode and uses
`canvas.openSubgraph()` instead of `navigateIntoSubgraph()` to avoid a
wrapper-specific DOM overlay duplication issue — entry-affordance
coverage lives in `subgraphNavigation.spec.ts`
- No product code changes — test-only migration

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10759-test-reorganize-subgraph-test-suite-into-composable-domain-specs-3336d73d365081b0a56bcbf809b1f584)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 15:04:33 -07:00
Johnpaul Chiwetelu
2189172f15 fix: Add timeout and abort mechanism for image upload (#9226) (#9491)
Closes #9226

## Summary

Image uploads had no timeout or abort mechanism, meaning a stalled
upload could hang indefinitely with no user feedback. This adds a
2-minute timeout using `AbortController` and shows a user-friendly toast
message when the upload times out.

## Changes

- `src/composables/node/useNodeImageUpload.ts`: Added `AbortController`
with a 120-second timeout to the `uploadFile` function. The abort signal
is passed to `fetchApi`. In the `handleUpload` error handler,
`AbortError` is now caught separately to display a localized timeout
message.
- `src/locales/en/main.json`: Added `uploadTimedOut` i18n translation
key.

---
Automated by coderabbit-fixer

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9491-fix-Add-timeout-and-abort-mechanism-for-image-upload-9226-31b6d73d365081d7a7d7f7016f3a71c6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: CodeRabbit Fixer <coderabbit-fixer@automated.bot>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-08 21:27:32 +00:00
Christian Byrne
9b769656ac test: add ShareWorkflow dialog E2E tests (DLG-05) (#10588)
## Summary
Adds Playwright E2E tests for the ShareWorkflow dialog component and its
various states.

## Tests added
- Dialog opens and shows unsaved state for new workflows (save prompt)
- Ready state shows create link button for saved but unpublished
workflows
- Shared state shows copy URL field with share link after publishing
- Stale state shows update link button when workflow modified after
publishing
- Close button dismisses dialog
- Create link transitions dialog from ready to shared state
- Tab switching between share link and publish to hub (when
comfyHubUploadEnabled)
- Tab aria-selected states update correctly on switch

## Approach
- Share dialog is gated behind `isCloud` (compile-time constant), so
tests invoke it directly via `page.evaluate()` importing
`useShareDialog`
- Share service API calls (`/api/userdata/*/publish`,
`/api/assets/from-workflow`) mocked via `page.route()` for deterministic
state testing
- Dialog state (loading → unsaved → ready → shared → stale) controlled
by mock responses
- Feature flags set via `serverFeatureFlags.value` for tab visibility
testing

## Notes
- All pre-existing TS2307 errors are `.vue` module resolution — no new
type errors
- Tests cover the 5 dialog states: loading, unsaved, ready, shared,
stale

## Task
Part of Test Coverage Q2 Overhaul (DLG-05).

## Conventions
- Uses Vue nodes with new menu enabled
- Tests read as user stories
- No full-page screenshots
- Proper waits, no sleeps
- All API calls mocked

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10588-test-add-ShareWorkflow-dialog-E2E-tests-DLG-05-3306d73d365081a0ab15f333707e493b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 14:14:47 -07:00
Christian Byrne
934f1487bd feat: add Comfy Design Standards Figma reference for agents (#10696)
## Summary

Add design standards instructions so agents consult the canonical [Comfy
Design
Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards)
Figma file before implementing user-facing features.

### Changes

- **`docs/guidance/design-standards.md`** — Auto-loaded guidance for
`src/components/**/*.vue` and `src/views/**/*.vue` with Figma MCP fetch
instructions, section node IDs, and component set references
- **`AGENTS.md`** — Added Design Standards section and Figma link in
External Resources

### Design

All content is fetched **live from Figma** via the Figma MCP tool —
designers can update the file and agents will always see the latest
version. No hardcoded design rules that can go stale.

Ref: COM-17639

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10696-feat-add-Comfy-Design-Standards-Figma-reference-for-agents-3326d73d36508181844fdcaa5c17cf00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-08 14:13:57 -07:00
Christian Byrne
6f98fe5ba7 docs: add staging environment setup instructions to CONTRIBUTING.md (#10775)
## Summary

Add a "Testing with Cloud & Staging Environments" section to
CONTRIBUTING.md documenting how to test partner/API nodes that require
cloud backend authentication.

## Changes

- **What**: New section in CONTRIBUTING.md between "Dev Server" and
"Access dev server on touch devices" explaining two approaches for
staging/cloud development:
1. Frontend approach: `pnpm dev:cloud` or custom
`DEV_SERVER_COMFYUI_URL` in `.env`
2. Backend approach: `--comfy-api-base https://stagingapi.comfy.org`
flag

## Review Focus

- Accuracy of the `--comfy-api-base` backend flag documentation (sourced
from internal Slack discussion)
- Whether the section placement and level of detail is appropriate

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10775-docs-add-staging-environment-setup-instructions-to-CONTRIBUTING-md-3346d73d36508112bcd4df6ecb7f83c6)
by [Unito](https://www.unito.io)
2026-04-08 14:11:01 -07:00
Rizumu Ayaka
44c3d08b56 perf: add preload and content-visibility attribute to media preview for improved performance (#10806)
## Summary

this pull request is to improve the performance of large workflows
containing numerous Media Previews.

Added content-visibility: auto, which enables the browser to lazily
render Media Previews outside the viewport.

Added preload="metadata", which makes <video> and <audio> elements only
preload metadata instead of the full content.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10806-perf-add-preload-and-content-visibility-attribute-to-media-preview-for-improved-perfor-3356d73d365081238ce8f1a82f8694ec)
by [Unito](https://www.unito.io)
2026-04-08 14:08:47 -07:00
Dante
537e4bc4f2 test: add E2E tests for settings dialog (#10797)
## Summary
- Adds Playwright E2E tests for the Settings dialog covering behaviors
not tested elsewhere:
- About panel displays version badges (navigates to About, verifies
content)
- Boolean setting toggle through the UI persists the value (uses search
+ ToggleSwitch click, verifies via API)
- Dialog can be closed via the close button (complements existing
Escape-key test)

## Test plan
- [ ] `pnpm test:browser:local -- --grep "Settings dialog"` passes
- [ ] No overlap with existing tests in `dialog.spec.ts` or
`useSettingSearch.spec.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10797-test-add-E2E-tests-for-settings-dialog-3356d73d365081e4a3adcd3979048444)
by [Unito](https://www.unito.io)
2026-04-09 05:29:53 +09:00
pythongosssss
4b0b8e7240 test: App mode - Welcome screen state (#10747)
## Summary

Adds tests for validating welcome screen state

## Changes

- **What**: 
- add clear graph util function

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10747-test-App-mode-Welcome-screen-state-3336d73d365081f0ba27d567c3c81505)
by [Unito](https://www.unito.io)
2026-04-08 12:33:51 -07:00
384 changed files with 25294 additions and 10596 deletions

View File

@@ -64,15 +64,16 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && hashFiles('reports/**') != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -7,7 +7,6 @@ on:
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
workflow_dispatch:
concurrency:
@@ -15,7 +14,36 @@ concurrency:
cancel-in-progress: true
jobs:
# Detect whether e2e-relevant files changed. Required checks see "skipped"
# (which counts as passing) when only docs/apps/storybook files are touched,
# avoiding the stall that paths-ignore would cause.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
steps:
- name: Checkout repository
if: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@v6
- name: Check for e2e-relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
e2e:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'
setup:
needs: changes
if: ${{ needs.changes.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -153,9 +181,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [playwright-tests-chromium-sharded]
needs: [changes, playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -184,14 +212,35 @@ jobs:
path: ./playwright-report/
retention-days: 30
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
steps:
@@ -210,9 +259,15 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports]
needs: [changes, playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
if: >-
${{
always() &&
needs.changes.outputs.should_run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
permissions:
pull-requests: write
contents: read

View File

@@ -0,0 +1,90 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
- name: Save PR metadata
run: |
mkdir -p temp/vercel-preview
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Build project artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,74 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
permissions:
contents: read
pull-requests: write
actions: read
jobs:
comment:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
- name: Read preview URL
if: steps.pr-meta.outputs.skip != 'true'
id: meta
run: |
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
- name: Write report
if: steps.pr-meta.outputs.skip != 'true'
run: |
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
- name: Post PR comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/post-pr-report-comment
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
report-file: ./preview-report.md
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -142,10 +142,22 @@ jobs:
fi
echo "✅ Branch '$BRANCH' exists"
- name: Ensure packageManager field exists
run: |
if ! grep -q '"packageManager"' package.json; then
# Old branches (e.g. core/1.42) predate the packageManager field.
# Inject it so pnpm/action-setup can resolve the version.
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
pkg.packageManager = 'pnpm@10.33.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Injected packageManager into package.json for legacy branch"
fi
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -64,6 +64,7 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -104,8 +105,7 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
"vue/no-import-compiler-macros": "error"
},
"overrides": [
{

View File

@@ -179,6 +179,12 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
24. Do not use function expressions if it's possible to use function declarations instead
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Design Standards
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
## Testing Guidelines
See @docs/testing/\*.md for detailed patterns.
@@ -226,6 +232,7 @@ See @docs/testing/\*.md for detailed patterns.
- shadcn/vue: <https://www.shadcn-vue.com/>
- Reka UI: <https://reka-ui.com/>
- PrimeVue: <https://primevue.org>
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
@@ -311,6 +318,9 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -62,6 +62,37 @@ python main.py --port 8188 --cpu
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
#### Testing with Cloud & Staging Environments
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
**Option 1: Frontend — `pnpm dev:cloud`**
The simplest approach. This proxies all API requests to the test cloud environment:
```bash
pnpm dev:cloud
```
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
```bash
# .env
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
```
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
**Option 2: Backend — `--comfy-api-base`**
Alternatively, launch the ComfyUI backend pointed at the staging API:
```bash
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
```
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.

View File

@@ -4,6 +4,9 @@
"outputDirectory": "apps/website/dist",
"installCommand": "pnpm install --frozen-lockfile",
"framework": null,
"github": {
"enabled": false
},
"redirects": [
{
"source": "/pricing",

View File

@@ -0,0 +1,34 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
@@ -31,7 +31,7 @@
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"pos": [450, 200],
"size": [315, 314],
"flags": {},
"order": 1,

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,

View File

@@ -1,7 +1,27 @@
{
"last_node_id": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
@@ -15,7 +35,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
],
"version": 0.4

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,66 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_a.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [500, 100],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model_b.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model_a.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
},
{
"name": "fake_model_b.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -34,7 +34,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
]
},

View File

@@ -0,0 +1,141 @@
{
"id": "test-missing-models-in-bypassed-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,141 @@
{
"id": "test-missing-models-in-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -78,7 +78,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
],
"version": 0.4

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

@@ -0,0 +1,179 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [225, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [270, 108],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [270, 104],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [225, 104],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -34,6 +34,7 @@ import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
@@ -181,6 +182,7 @@ export class ComfyPage {
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -232,6 +234,7 @@ export class ComfyPage {
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
get visibleToasts() {
@@ -444,6 +447,10 @@ export const comfyPageFixture = base.extend<{
console.error(e)
}
if (testInfo.tags.includes('@cloud')) {
await comfyPage.cloudAuth.mockAuth()
}
await comfyPage.setup()
const needsPerf =

View File

@@ -2,9 +2,13 @@ import type { Locator, Page } from '@playwright/test'
export class TemplatesDialog {
public readonly root: Locator
public readonly modelFilter: Locator
public readonly resultsCount: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.modelFilter = this.root.getByRole('button', { name: /Model Filter/ })
this.resultsCount = this.root.getByText(/Showing.*of.*templates/i)
}
filterByHeading(name: string): Locator {
@@ -16,4 +20,10 @@ export class TemplatesDialog {
getCombobox(name: RegExp | string): Locator {
return this.root.getByRole('combobox', { name })
}
async selectModelOption(name: string): Promise<void> {
await this.modelFilter.click()
await this.page.getByRole('option', { name }).click()
await this.page.keyboard.press('Escape')
}
}

View File

@@ -0,0 +1,28 @@
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
): TemplateInfo {
return {
description: overrides.name,
mediaType: 'image',
mediaSubtype: 'webp',
...overrides
}
}
export function mockTemplateIndex(
templates: TemplateInfo[]
): WorkflowTemplates[] {
return [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates
}
]
}

View File

@@ -53,9 +53,8 @@ export class AppModeHelper {
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
@@ -124,6 +123,31 @@ export class AppModeHelper {
.getByRole('button', { name: /run/i })
}
/** The welcome screen shown when app mode has no outputs or no nodes. */
get welcome(): Locator {
return this.page.getByTestId(TestIds.appMode.welcome)
}
/** The empty workflow message shown when no nodes exist. */
get emptyWorkflowText(): Locator {
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
}
/** The "Build app" button shown when nodes exist but no outputs. */
get buildAppButton(): Locator {
return this.page.getByTestId(TestIds.appMode.buildApp)
}
/** The "Back to workflow" button on the welcome screen. */
get backToWorkflowButton(): Locator {
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
}
/** The "Load template" button shown when no nodes exist. */
get loadTemplateButton(): Locator {
return this.page.getByTestId(TestIds.appMode.loadTemplate)
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -0,0 +1,171 @@
import type { Page } from '@playwright/test'
/**
* Mocks Firebase authentication for cloud E2E tests.
*
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
* configured, so the user is never authenticated and the app redirects
* to `/cloud/login`.
*
* This helper seeds Firebase's IndexedDB persistence layer with a mock
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
* so the SDK believes a user is signed in. Must be called before navigation.
*/
export class CloudAuthHelper {
constructor(private readonly page: Page) {}
/**
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
*/
async mockAuth(): Promise<void> {
await this.seedFirebaseIndexedDB()
await this.mockFirebaseEndpoints()
}
/**
* Navigate to a lightweight same-origin page to seed Firebase's
* IndexedDB persistence with a mock user. This ensures the data
* is written before the app loads and Firebase reads it.
*
* Firebase auth uses `browserLocalPersistence` which stores data in
* IndexedDB database `firebaseLocalStorageDb`, object store
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
*/
private async seedFirebaseIndexedDB(): Promise<void> {
// Navigate to a lightweight endpoint to get a same-origin context
await this.page.goto('http://localhost:8188/api/users')
await this.page.evaluate(() => {
const MOCK_USER_DATA = {
uid: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
isAnonymous: false,
providerData: [
{
providerId: 'google.com',
uid: 'test-user-e2e',
displayName: 'E2E Test User',
email: 'e2e@test.comfy.org',
phoneNumber: null,
photoURL: null
}
],
stsTokenManager: {
refreshToken: 'mock-refresh-token',
accessToken: 'mock-firebase-id-token',
expirationTime: Date.now() + 60 * 60 * 1000
},
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
appName: '[DEFAULT]'
}
const DB_NAME = 'firebaseLocalStorageDb'
const STORE_NAME = 'firebaseLocalStorage'
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(DB_NAME)
request.onerror = () => reject(request.error)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close()
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
upgradeReq.onerror = () => reject(upgradeReq.error)
upgradeReq.onupgradeneeded = () => {
const upgradedDb = upgradeReq.result
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
upgradedDb.createObjectStore(STORE_NAME)
}
}
upgradeReq.onsuccess = () => {
const upgradedDb = upgradeReq.result
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
upgradedDb.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
return
}
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(
{ fpiVersion: '1', value: MOCK_USER_DATA },
KEY
)
tx.oncomplete = () => {
db.close()
resolve()
}
tx.onerror = () => reject(tx.error)
}
})
})
}
/**
* Intercept Firebase Auth REST API endpoints so the SDK can
* "refresh" the mock user's token without real credentials.
*/
private async mockFirebaseEndpoints(): Promise<void> {
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'mock-access-token',
expires_in: '3600',
token_type: 'Bearer',
refresh_token: 'mock-refresh-token',
id_token: 'mock-firebase-id-token',
user_id: 'test-user-e2e',
project_id: 'dreamboothy-dev'
})
})
)
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
kind: 'identitytoolkit#GetAccountInfoResponse',
users: [
{
localId: 'test-user-e2e',
email: 'e2e@test.comfy.org',
displayName: 'E2E Test User',
emailVerified: true,
validSince: '0',
lastLoginAt: String(Date.now()),
createdAt: String(Date.now()),
lastRefreshAt: new Date().toISOString()
}
]
})
})
)
await this.page.route('**/__/auth/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html><body></body></html>'
})
)
}
}

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'
@@ -30,6 +33,12 @@ export class NodeOperationsHelper {
})
}
/** Remove all nodes from the graph and clean. */
async clearGraph() {
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await this.comfyPage.command.executeCommand('Comfy.ClearWorkflow')
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
@@ -108,6 +117,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,
@@ -182,3 +212,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

@@ -116,6 +116,14 @@ export class WorkflowHelper {
})
}
async waitForActiveWorkflow(): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
(window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow !== null
)
}
async getActiveWorkflowPath(): Promise<string | undefined> {
return this.comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow

View File

@@ -0,0 +1,95 @@
import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
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
const isLegacyCanvasNodeWithoutVueDom = !nodeEl
if (isLegacyCanvasNodeWithoutVueDom) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (!node) {
throw new Error(`Node ${id} not found in graph`)
}
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_BOUNDS_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -55,6 +55,9 @@ export const TestIds = {
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
@@ -63,7 +66,8 @@ export const TestIds = {
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -78,7 +82,8 @@ export const TestIds = {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel'
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
@@ -130,7 +135,12 @@ export const TestIds = {
connectOutputPopover: 'builder-connect-output-popover'
},
appMode: {
widgetItem: 'app-mode-widget-item'
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

@@ -5,6 +5,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
return {
@@ -332,6 +333,22 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/**
* Toggle the node's collapsed state by simulating the same user interaction
* the runtime uses: DOM collapse button click in Vue mode, canvas icon click
* in legacy mode. Mode is detected by the presence of a Vue-rendered DOM
* element with `data-node-id`.
*/
async toggleCollapse() {
const vueLocator = this.comfyPage.page.locator(
`[data-node-id="${this.id}"]`
)
if ((await vueLocator.count()) > 0) {
await new VueNodeFixture(vueLocator).toggleCollapse()
return
}
await this.click('collapse')
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -1,15 +1,8 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (
Array.isArray(entry) &&
entry.length === 2 &&
@@ -18,9 +11,7 @@ export function isPromotedWidgetEntry(
)
}
export function normalizePromotedWidgets(
value: unknown
): PromotedWidgetEntry[] {
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
@@ -37,28 +28,6 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -75,7 +44,7 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
@@ -87,14 +56,6 @@ export async function getPseudoPreviewWidgets(
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -0,0 +1,61 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Empty workflow text is visible when no nodes', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
test('Build app button is visible when no outputs selected', async ({
comfyPage
}) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).not.toBeVisible()
await expect(comfyPage.appMode.buildAppButton).not.toBeVisible()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).not.toBeVisible()
})
test('Load template opens template selector', async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.loadTemplateButton.click()
await expect(comfyPage.templates.content).toBeVisible()
})
})

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,431 @@
import { expect } from '@playwright/test'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import type { components as RegistryComponents } from '@comfyorg/registry-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
type InstalledPacksResponse =
ManagerComponents['schemas']['InstalledPacksResponse']
type RegistryNodePack = RegistryComponents['schemas']['Node']
interface AlgoliaSearchResult {
hits: Partial<AlgoliaNodePack>[]
nbHits: number
page: number
nbPages: number
hitsPerPage: number
}
interface AlgoliaSearchResponse {
results: AlgoliaSearchResult[]
}
const MOCK_PACK_A: RegistryNodePack = {
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
downloads: 5000,
status: 'NodeStatusActive',
publisher: { id: 'test-publisher', name: 'Test Publisher' },
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-a',
tags: ['image', 'processing']
}
const MOCK_PACK_B: RegistryNodePack = {
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack for testing search',
downloads: 3000,
status: 'NodeStatusActive',
publisher: { id: 'another-publisher', name: 'Another Publisher' },
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-b',
tags: ['video', 'generation']
}
const MOCK_PACK_C: RegistryNodePack = {
id: 'test-pack-c',
name: 'Test Pack C',
description: 'Third test pack',
downloads: 100,
status: 'NodeStatusActive',
publisher: { id: 'test-publisher', name: 'Test Publisher' },
latest_version: { version: '0.5.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-c'
}
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
'test-pack-a': {
ver: '1.0.0',
cnr_id: 'test-pack-a',
enabled: true
},
'test-pack-c': {
ver: '0.5.0',
cnr_id: 'test-pack-c',
enabled: false
}
}
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-a',
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
total_install: 5000,
status: 'NodeStatusActive',
publisher_id: 'test-publisher',
latest_version: '1.0.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-a',
comfy_nodes: ['TestNodeA1', 'TestNodeA2'],
create_time: '2024-01-01T00:00:00Z',
update_time: '2024-06-01T00:00:00Z',
license: 'MIT',
tags: ['image', 'processing']
}
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-b',
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack for testing search',
total_install: 3000,
status: 'NodeStatusActive',
publisher_id: 'another-publisher',
latest_version: '2.1.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-b',
comfy_nodes: ['TestNodeB1'],
create_time: '2024-02-01T00:00:00Z',
update_time: '2024-07-01T00:00:00Z',
license: 'Apache-2.0',
tags: ['video', 'generation']
}
const MOCK_HIT_C: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-c',
id: 'test-pack-c',
name: 'Test Pack C',
description: 'Third test pack',
total_install: 100,
status: 'NodeStatusActive',
publisher_id: 'test-publisher',
latest_version: '0.5.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-c',
comfy_nodes: ['TestNodeC1'],
create_time: '2024-03-01T00:00:00Z',
update_time: '2024-05-01T00:00:00Z',
license: 'MIT'
}
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
results: [
{
hits: [MOCK_HIT_A, MOCK_HIT_B, MOCK_HIT_C],
nbHits: 3,
page: 0,
nbPages: 1,
hitsPerPage: 20
}
]
}
const MOCK_ALGOLIA_PACK_B_ONLY: AlgoliaSearchResponse = {
results: [
{
hits: [MOCK_HIT_B],
nbHits: 1,
page: 0,
nbPages: 1,
hitsPerPage: 20
}
]
}
const MOCK_ALGOLIA_EMPTY: AlgoliaSearchResponse = {
results: [
{
hits: [],
nbHits: 0,
page: 0,
nbPages: 0,
hitsPerPage: 20
}
]
}
test.describe('ManagerDialog', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
const statsWithManager = {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: statsWithManager })
})
await comfyPage.page.route(
'**/v2/customnode/installed**',
async (route) => {
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
}
)
await comfyPage.page.route(
'**/v2/manager/queue/status**',
async (route) => {
await route.fulfill({
json: {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
})
}
)
await comfyPage.page.route(
'**/v2/manager/queue/history**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
// Mock Comfy Registry API (fallback when Algolia credentials are unavailable)
const registryListResponse = {
total: 3,
nodes: [MOCK_PACK_A, MOCK_PACK_B, MOCK_PACK_C],
page: 1,
limit: 64,
totalPages: 1
}
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
'**/v2/customnode/getmappings**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.page.route(
'**/v2/customnode/import_fail_info**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.setup()
// Seed manager-ready server feature flags AFTER setup so the WebSocket
// feature_flags payload can't overwrite them. mockServerFeatures (on
// /api/features) does not populate the serverFeatureFlags ref; direct
// reactive-ref mutation is the only reliable approach.
// See shareWorkflowDialog.spec.ts:34-48 for the canonical pattern.
await comfyPage.page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
extension: {
manager: {
supports_v4: true,
supports_csrf_post: true
}
}
}
})
})
async function openManagerDialog(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
}
test('Opens the manager dialog via command', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
})
test('Displays pack cards from search results', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Test Pack A')).toBeVisible()
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack C')).toBeVisible()
})
test('Search filters displayed packs', async ({ comfyPage }) => {
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_PACK_B_ONLY })
})
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({
json: {
total: 1,
nodes: [MOCK_PACK_B],
page: 1,
limit: 64,
totalPages: 1
}
})
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const searchInput = dialog.getByPlaceholder(/search/i)
await searchInput.fill('Test Pack B')
await expect(dialog.getByText('Test Pack B')).toBeVisible()
await expect(dialog.getByText('Test Pack A')).toBeHidden()
})
test('Clicking a pack card opens the info panel', async ({ comfyPage }) => {
await comfyPage.page.route(
'**/api.comfy.org/nodes/test-pack-a',
async (route) => {
await route.fulfill({ json: MOCK_PACK_A })
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await dialog.getByText('Test Pack A').first().click()
await expect(dialog.getByText('Test Publisher').first()).toBeVisible()
})
test('Left side panel navigation tabs exist', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const nav = dialog.locator('nav')
await expect(nav.getByText('All Extensions')).toBeVisible()
await expect(nav.getByText('Not Installed')).toBeVisible()
await expect(nav.getByText('All Installed')).toBeVisible()
await expect(nav.getByText('Updates Available')).toBeVisible()
})
test('Switching tabs changes the content view', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const nav = dialog.locator('nav')
await nav.getByText('All Installed').click()
await expect(dialog.getByText('Test Pack A')).toBeVisible()
})
test('Closes via Escape key', async ({ comfyPage }) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
test('Empty search shows no results message', async ({ comfyPage }) => {
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_EMPTY })
})
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({
json: {
total: 0,
nodes: [],
page: 1,
limit: 64,
totalPages: 0
}
})
}
)
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const searchInput = dialog.getByPlaceholder(/search/i)
await searchInput.fill('nonexistent-pack-xyz-999')
await expect(
dialog.getByText(/no results found|try a different search/i).first()
).toBeVisible()
})
test('Search mode can be switched between packs and nodes', async ({
comfyPage
}) => {
await openManagerDialog(comfyPage)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const modeSelector = dialog.getByText('Node Pack').first()
await expect(modeSelector).toBeVisible()
await modeSelector.click()
const nodesOption = comfyPage.page.getByRole('option', { name: 'Nodes' })
await expect(nodesOption).toBeVisible()
await nodesOption.click()
})
})

View File

@@ -0,0 +1,198 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { mockSystemStats } from '../../fixtures/data/systemStats'
const MOCK_COMFYUI_VERSION = '9.99.0-e2e-test'
test.describe('Settings dialog', { tag: '@ui' }, () => {
test('About panel renders mocked version from server', async ({
comfyPage
}) => {
const stats = {
...mockSystemStats,
system: {
...mockSystemStats.system,
comfyui_version: MOCK_COMFYUI_VERSION
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: stats })
})
await comfyPage.setup()
const dialog = comfyPage.settingDialog
await dialog.open()
await dialog.goToAboutPanel()
const aboutPanel = comfyPage.page.getByTestId('about-panel')
await expect(aboutPanel).toBeVisible()
await expect(aboutPanel).toContainText(MOCK_COMFYUI_VERSION)
await expect(aboutPanel).toContainText('ComfyUI_frontend')
})
test('Toggling a boolean setting through UI persists the value', async ({
comfyPage
}) => {
const settingId = 'Comfy.Validation.Workflows'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Validate workflows')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
await settingRow.locator('.p-toggleswitch').click()
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Can be closed via close button', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await dialog.close()
await expect(dialog.root).not.toBeVisible()
})
test('Escape key closes dialog', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
await expect(dialog.root).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
})
test('Search filters settings list', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
})
test('Search can be cleared to restore all settings', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const settingItems = dialog.root.locator('[data-setting-id]')
const countBeforeSearch = await settingItems.count()
await dialog.searchBox.fill('Validate workflows')
await expect
.poll(() => settingItems.count())
.toBeLessThan(countBeforeSearch)
await dialog.searchBox.clear()
await expect.poll(() => settingItems.count()).toBe(countBeforeSearch)
})
test('Category navigation changes content area', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
const firstCategory = dialog.categories.first()
const firstCategoryName = await firstCategory.textContent()
await firstCategory.click()
const firstContent = await dialog.contentArea.textContent()
// Find a different category to click
const categoryCount = await dialog.categories.count()
let switched = false
for (let i = 1; i < categoryCount; i++) {
const cat = dialog.categories.nth(i)
const catName = await cat.textContent()
if (catName !== firstCategoryName) {
await cat.click()
await expect
.poll(() => dialog.contentArea.textContent())
.not.toBe(firstContent)
switched = true
break
}
}
expect(switched).toBe(true)
})
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
try {
await comfyPage.settings.setSetting(settingId, !initialValue)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.LiteGraph!.middle_click_slot_add_default_node
)
)
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {
const settingId = 'Comfy.UseNewMenu'
const initialValue = await comfyPage.settings.getSetting<string>(settingId)
const dialog = comfyPage.settingDialog
await dialog.open()
try {
await dialog.searchBox.fill('Use new menu')
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Click the PrimeVue Select to open the dropdown
await settingRow.locator('.p-select').click()
const overlay = comfyPage.page.locator('.p-select-overlay')
await expect(overlay).toBeVisible()
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'
await overlay
.locator(`.p-select-option-label:text-is("${targetValue}")`)
.click()
await expect
.poll(() => comfyPage.settings.getSetting<string>(settingId))
.toBe(targetValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
})

View File

@@ -0,0 +1,352 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AssetInfo } from '../../../src/schemas/apiSchema'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
interface PublishRecord {
workflow_id: string
share_id: string | null
listed: boolean
publish_time: string | null
}
const PUBLISHED_RECORD: PublishRecord = {
workflow_id: 'wf-1',
share_id: 'share-abc',
listed: false,
publish_time: new Date(Date.now() + 60_000).toISOString()
}
const PRIVATE_ASSET: AssetInfo = {
id: 'asset-1',
name: 'photo.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const test = comfyPageFixture
/**
* Enable the workflow_sharing_enabled server feature flag at runtime.
* FeatureFlagHelper.mockServerFeatures() intercepts `/api/features` but the
* flags are already loaded by the time tests run, so direct mutation of the
* reactive ref is the only reliable approach for server-side flags.
*/
async function enableWorkflowSharing(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
workflow_sharing_enabled: true
}
})
}
async function mockPublishStatus(
page: Page,
record: PublishRecord | null
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'GET') {
if (!record || !record.share_id) {
await route.fulfill({ status: 404, body: 'Not found' })
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(record)
})
}
} else {
await route.fallback()
}
})
}
async function mockPublishWorkflow(
page: Page,
result: PublishRecord
): Promise<void> {
await page.route('**/api/userdata/*/publish', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result)
})
} else {
await route.fallback()
}
})
}
async function mockShareableAssets(
page: Page,
assets: AssetInfo[] = []
): Promise<void> {
await page.route('**/api/assets/from-workflow', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ assets })
})
})
}
/**
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
* or auth-triggered modals by pressing Escape until they clear.
*/
async function dismissOverlays(page: Page): Promise<void> {
const mask = page.locator('.p-dialog-mask')
for (let attempt = 0; attempt < 3; attempt++) {
if ((await mask.count()) === 0) break
await page.keyboard.press('Escape')
await mask
.first()
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {})
}
}
/**
* Save the active workflow via the topbar Save menu action.
* Mocks the userdata POST endpoint to avoid real server calls in tests.
*/
async function saveAndWait(
comfyPage: {
page: Page
menu: { topbar: { saveWorkflow: (name: string) => Promise<void> } }
},
workflowName: string
): Promise<void> {
const filename =
workflowName + (workflowName.endsWith('.json') ? '' : '.json')
await comfyPage.page.route(
/\/api\/userdata\/workflows(%2F|\/).*$/,
async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
path: `workflows/${filename}`,
size: 1024,
modified: Date.now()
})
})
} else {
await route.fallback()
}
}
)
await comfyPage.menu.topbar.saveWorkflow(workflowName)
}
async function openShareDialog(page: Page): Promise<void> {
await enableWorkflowSharing(page)
await dismissOverlays(page)
const shareButton = page.getByRole('button', { name: 'Share workflow' })
await shareButton.click()
}
function getShareDialog(page: Page) {
return page.getByRole('dialog')
}
test.describe('Share Workflow Dialog', { tag: '@cloud' }, () => {
test('should show unsaved state for a new workflow', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /save workflow/i })
).toBeVisible()
})
test('should show ready state with create link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ready'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /create a link/i })
).toBeVisible()
})
test('should show shared state with copy URL after publishing', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-shared'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, PUBLISHED_RECORD)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show stale state with update link button', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-stale'
await saveAndWait(comfyPage, workflowName)
const staleRecord: PublishRecord = {
...PUBLISHED_RECORD,
publish_time: '2020-01-01T00:00:00Z'
}
await mockPublishStatus(page, staleRecord)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: /update\s+link/i })
).toBeVisible()
})
test('should close dialog when close button is clicked', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /close/i }).click()
await expect(dialog).toBeHidden()
})
test('should create link and transition to shared state', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-create'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await mockPublishWorkflow(page, PUBLISHED_RECORD)
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeVisible()
await createButton.click()
await expect(
dialog.getByRole('textbox', { name: /share.*url/i })
).toBeVisible()
})
test('should show tab buttons when comfyHubUploadEnabled is true', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('tab', { name: /share/i })).toBeVisible()
await expect(dialog.getByRole('tab', { name: /publish/i })).toBeVisible()
})
test('should switch between share link and publish tabs', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
comfyhub_upload_enabled: true
}
})
await mockPublishStatus(page, null)
await mockShareableAssets(page)
await openShareDialog(page)
const dialog = getShareDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('tab', { name: /publish/i }).click()
const publishPanel = dialog.getByTestId(TestIds.dialogs.publishTabPanel)
await expect(publishPanel).toBeVisible()
await dialog.getByRole('tab', { name: /share/i }).click()
await expect(publishPanel).toBeHidden()
})
test('should require acknowledgment before publishing private assets', async ({
comfyPage
}) => {
const { page } = comfyPage
const workflowName = 'share-test-ack'
await saveAndWait(comfyPage, workflowName)
await mockPublishStatus(page, null)
await mockShareableAssets(page, [PRIVATE_ASSET])
await openShareDialog(page)
const dialog = getShareDialog(page)
const createButton = dialog.getByRole('button', { name: /create a link/i })
await expect(createButton).toBeDisabled()
await dialog.getByRole('checkbox').check()
await expect(createButton).toBeEnabled()
})
})

View File

@@ -5,6 +5,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -47,11 +48,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await cleanupFakeModel(comfyPage)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -95,7 +92,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -107,10 +104,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await expect(errorOverlay).toBeHidden()
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await expect(errorOverlay).toBeHidden()
})
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = getOverlay(comfyPage.page)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeHidden()
})
})
@@ -151,6 +175,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -162,7 +187,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -175,10 +200,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -189,7 +212,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
})
test.describe('Count independence from node selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('missing model count stays constant when a node is selected', async ({
comfyPage
}) => {
// Regression: ErrorOverlay previously read the selection-filtered
// missingModelGroups from useErrorGroups, so selecting one of two
// missing-model nodes would shrink the overlay label from
// "2 required models are missing" to "1". The overlay must show
// the workflow total regardless of canvas selection.
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toContainText(/2 required models are missing/i)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await expect(overlay).toContainText(/2 required models are missing/i)
})
})
})

View File

@@ -0,0 +1,54 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('File input same-file reselection', () => {
test('should allow uploading the same file twice via LoadImage node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const loadImageNode = loadImageNodes[0]
const uploadWidget = await loadImageNode.getWidget(1)
const fileWidget = await loadImageNode.getWidget(0)
// First upload
const firstUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const firstChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await firstChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await firstUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'First upload should set widget value'
})
.toContain('test_upload_image')
// Second upload of the SAME file — before the fix, the hidden input
// retained the previous value and onchange did not fire.
const secondUpload = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
const secondChooser = comfyPage.page.waitForEvent('filechooser')
await uploadWidget.click()
await (
await secondChooser
).setFiles(comfyPage.assetPath('test_upload_image.png'))
await secondUpload
await expect
.poll(() => fileWidget.getValue(), {
message: 'Second upload of the same file should still set widget value'
})
.toContain('test_upload_image')
})
})

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.describe(
'Node context menu viewport overflow (#10824)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler toward the lower-left so the menu has limited space below it.
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()!
const centerX = viewportSize.width / 3
const centerY = viewportSize.height * 0.75
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const rootList = menu.locator(':scope > ul')
await expect
.poll(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

@@ -158,5 +158,73 @@ for (const mode of ['litegraph', 'vue'] as const) {
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test(
'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph',
{ tag: ['@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'default'
)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl.FollowCursor',
true
)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
if (mode === 'vue') {
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('2')
} else {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
}
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// `subgraph.getActiveGraphId()` is not on core/1.43; inline via app state
const getActiveGraphId = () =>
comfyPage.page.evaluate(() => window.app!.canvas?.graph?.id ?? null)
const subgraphId = await getActiveGraphId()
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
// `searchBoxV2.open()` is not on core/1.43; the canvas double-click
// opens the v2 search box when Comfy.NodeSearchBoxImpl is set to default
const { searchBoxV2 } = comfyPage
await comfyPage.canvas.dblclick({ position: { x: 400, y: 300 } })
await expect(searchBoxV2.input).toBeVisible()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.app!.canvas.state.ghostNodeId != null
)
)
.toBe(true)
await comfyPage.page.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
'Escape during ghost placement should cancel the ghost, not exit the subgraph'
})
.toBe(true)
await expect.poll(() => getActiveGraphId()).toBe(subgraphId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId)
)
.toBeNull()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount)
}
)
})
}

View File

@@ -2,8 +2,9 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
export async function openErrorsTabViaSeeErrors(
export async function loadWorkflowAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: string
) {
@@ -15,3 +16,30 @@ export async function openErrorsTabViaSeeErrors(
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
export async function openErrorsTab(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect(errorsTab).toBeVisible()
await errorsTab.click()
}
/**
* Remove the fake model file from the backend so it is detected as missing.
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
* during tests — they only serve as metadata for the missing model UI.
*/
export async function cleanupFakeModel(comfyPage: ComfyPage) {
await expect
.poll(() =>
comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
)
.toBeTruthy()
}

View File

@@ -3,7 +3,7 @@ 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'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -47,7 +47,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -57,7 +60,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
@@ -68,7 +71,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
@@ -81,7 +87,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -95,7 +104,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -121,7 +133,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -140,7 +155,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -154,7 +172,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -6,7 +6,10 @@ import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -15,17 +18,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await cleanupFakeModel(comfyPage)
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
@@ -35,7 +34,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
@@ -46,7 +45,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_with_nodes'
)
@@ -54,7 +53,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).not.toBeVisible()
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
@@ -66,14 +65,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().click()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
@@ -83,7 +82,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
@@ -94,12 +93,65 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
})
test('Should render Download all and Refresh actions for one downloadable model', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelActions)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelDownloadAll)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
).toBeVisible()
})
test('Should clear resolved missing model when Refresh is clicked', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await comfyPage.page.route(/\/object_info$/, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
})
})

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)

View File

@@ -0,0 +1,599 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
cleanupFakeModel,
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Missing nodes', () => {
test('Deleting a missing node removes its error from the errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingNodeGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
})
})
test.describe('Missing models', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
comfyPage
}) => {
// Regression: on un-bypass, the realtime scan produced a fresh
// candidate without url/hash/directory — those fields were only
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
// row's Copy URL button (v-if gated on representative.url) then
// disappeared. Per-node scan now enriches from node.properties.models
// which persists across mode toggles. Uses the `_from_node_properties`
// fixture because the enrichment source is per-node metadata, not
// the workflow-level `models[]` array (which the realtime scan
// path does not see).
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(copyUrlButton.first()).toBeVisible()
})
test('Pasting a node with missing model increases referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node with missing model removes its error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingModelGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_with_nodes'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
})
})
test.describe('Missing media', () => {
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingMediaGroup).toBeVisible()
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const mediaRows = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
await openErrorsTab(comfyPage)
await expect(mediaRows).toHaveCount(2)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await expect(mediaRows).toHaveCount(1)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Deleting a node inside a subgraph removes its missing model error', async ({
comfyPage
}) => {
// Regression: before the execId fix, onNodeRemoved fell back to the
// interior node's local id (e.g. "1") when node.graph was already
// null, so the error keyed under "2:1" was never removed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingNodeGroup).toBeHidden()
})
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
comfyPage
}) => {
// Regression: the initial scan pipeline only checked each node's
// own mode, so interior nodes of a bypassed subgraph container
// surfaced errors even though the container was excluded from
// execution. The pipeline now post-filters candidates whose
// ancestor path is not fully active.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
comfyPage
}) => {
// Regression: useGraphNodeManager replays graph.onNodeAdded for
// each interior node on subgraph entry; without an ancestor-aware
// guard in scanSingleNodeErrors, that re-scan reintroduced the
// error that the initial pipeline had correctly suppressed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_bypassed_subgraph'
)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await expect(errorsTab).toBeHidden()
})
})
test.describe('Workflow switching', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
})
test('Restores missing nodes in errors tab when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await expect(missingNodeGroup).toBeHidden()
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,235 @@
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'
type Layout = { ref: [number, number]; target: [number, number] }
const LAYOUTS: Record<string, Layout> = {
'bottom-left': { ref: [200, 100], target: [150, 500] },
'bottom-right': { ref: [100, 100], target: [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 toggleBypass(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 vueCases: ReadonlyArray<{
type: NodeType
state: NodeState
pos: Position
}> = [
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
]
for (const { type, state, pos } of vueCases) {
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]: LAYOUTS[pos].ref,
[targetId]: LAYOUTS[pos].target
})
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 nodeRef.toggleCollapse()
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]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await toggleBypass(comfyPage, nodeRef)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(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]: LAYOUTS['bottom-right'].ref,
[REGULAR_ID]: LAYOUTS['bottom-right'].target
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
await toggleBypass(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 legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
{ state: 'expanded', pos: 'bottom-left' },
{ state: 'expanded', pos: 'bottom-right' },
{ state: 'collapsed', pos: 'bottom-left' },
{ state: 'collapsed', pos: 'bottom-right' }
]
for (const { state, pos } of legacyCases) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
[REGULAR_ID]: LAYOUTS[pos].target
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.toggleCollapse()
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
)

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -232,7 +233,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Reports missing nodes warning again when switching back to workflow', async ({
test('Restores missing nodes errors silently when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -254,11 +255,17 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
// Switch back to the missing_nodes workflow — overlay should NOT
// reappear (silent restore), but errors tab should have content
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeVisible()
await expect(errorOverlay).toBeHidden()
// Errors tab should still show missing nodes after silent restore
await openErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -1,19 +1,34 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Constants
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
async function duplicateSubgraphNodeViaAltDrag(
comfyPage: ComfyPage
): Promise<void> {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const subgraphPos = await subgraphNode.getPosition()
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
try {
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
} finally {
await comfyPage.page.keyboard.up('Alt')
}
}
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
@@ -37,18 +52,14 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.link != null
(input) => input.link != null
).length
return { linkCount, linkedInputCount, nodeCount: nodes.length }
})
expect(result).not.toHaveProperty('error')
// Should have exactly 1 link (EmptyLatentImage→KSampler)
// not 4 (with 3 duplicates). The KSampler→output link is dropped
// because the subgraph output has no downstream connection.
expect(result.linkCount).toBe(1)
// KSampler should have exactly 1 linked input (latent_image)
expect(result.linkedInputCount).toBe(1)
})
})
@@ -62,14 +73,15 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('5')
await node.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
expect(subgraphNodes.length).toBe(1)
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(1)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(1)
await expect.poll(() => comfyPage.subgraph.getNodeCount()).toBe(1)
})
test('Can delete subgraph node', async ({ comfyPage }) => {
@@ -82,69 +94,47 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.delete()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount - 1)
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
expect(await deletedNode.exists()).toBe(false)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount - 1)
await expect.poll(() => deletedNode.exists()).toBe(false)
})
test.describe('Subgraph copy and paste', () => {
test('Can copy subgraph node by dragging + alt', async ({
test.describe('Subgraph Copy', () => {
test('Can duplicate a subgraph node by alt-dragging', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect a second subgraph node to be created (2 total)
expect(subgraphNodes.length).toBe(2)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
})
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
test('Alt-dragging a subgraph node creates a new subgraph type', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await duplicateSubgraphNodeViaAltDrag(comfyPage)
// Get position of subgraph node
const subgraphPos = await subgraphNode.getPosition()
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
.length
)
.toBe(2)
// Alt + Click on the subgraph node
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
// Drag slightly to trigger the copy
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
// Find all subgraph nodes and expect all unique IDs
const subgraphNodes =
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
// Expect the second subgraph node to have a unique type
const nodeType1 = await subgraphNodes[0].getType()
const nodeType2 = await subgraphNodes[1].getType()
expect(nodeType1).not.toBe(nodeType2)

View File

@@ -2,209 +2,91 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '@e2e/helpers/promotedWidgets'
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
await comfyPage.subgraph.exitViaBreadcrumb()
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
}
)
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('5')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Removing the preview subgraph clears promoted preview state and DOM', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.delete()
expect(await subgraphNode.exists()).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
})

View File

@@ -3,15 +3,8 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Constants
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
nodeSearchContainer: '.node-search-container'
} as const
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
@@ -39,15 +32,7 @@ function hasVisibleNodeInViewport() {
}
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Breadcrumb and Workflow Context', () => {
test.describe('Subgraph Navigation and UI', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
@@ -62,18 +47,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Navigate into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 20000
})
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back and edit title
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
@@ -92,59 +71,40 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back into subgraph
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(breadcrumb).toBeVisible()
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
await expect(backButton).toHaveCount(0)
})
})
@@ -157,7 +117,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Change the Exit Subgraph keybinding from Escape to Alt+Q
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
commandId: 'Comfy.Graph.ExitSubgraph',
@@ -182,28 +141,26 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
}
])
// Reload the page
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Escape should stay inside the subgraph after the default binding is unset'
).toBe(true)
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
@@ -217,39 +174,36 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
await expect(
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
).toBeVisible()
// Verify we're in a subgraph
if (!(await comfyPage.subgraph.isInSubgraph())) {
throw new Error('Not in subgraph')
}
expect(
await comfyPage.subgraph.isInSubgraph(),
'Precondition failed: expected to be inside the subgraph before opening settings'
).toBe(true)
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
// Press Escape - should close dialog, not exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Dialog should be closed
await expect(
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeHidden()
// Should still be in subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
test.describe('Viewport', () => {
test.describe('Subgraph viewport restoration', () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
@@ -258,24 +212,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph!
const sgNode = graph._nodes.find((n) =>
'isSubgraphNode' in n
? (
n as unknown as { isSubgraphNode: () => boolean }
).isSubgraphNode()
: false
) as unknown as { subgraph?: typeof graph } | undefined
if (!sgNode?.subgraph) throw new Error('No subgraph node')
canvas.setGraph(sgNode.subgraph)
})
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -293,7 +235,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
timeout: 2_000
})
.toBe(true)
})
@@ -324,7 +266,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
@@ -336,61 +278,43 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Progress State', () => {
test.describe('Subgraph progress clear on navigation', () => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
await expect
.poll(
() =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId),
{ timeout: 2_000 }
)
.toBeUndefined()
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
@@ -418,21 +342,23 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(node) =>
typeof node.isSubgraphNode === 'function' &&
node.isSubgraphNode()
)
if (!subgraphNode) return { exists: false, progress: null }
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
return { exists: true, progress: subgraphNode.progress }
}),
{ timeout: 5_000 }
)
.toEqual({ exists: true, progress: undefined })
})
})
})

View File

@@ -4,177 +4,44 @@ import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe('Nested subgraph configure order', () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
test('Loads and queues without nested promotion resolution failures', async ({
comfyPage
}) => {
const { warnings } = SubgraphHelper.collectConsoleWarnings(
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
comfyPage.page,
['No link found', 'Failed to resolve legacy -1']
)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
expect(warnings).toEqual([])
})
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode()
? hostNode.subgraph
: null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved:
interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
const response = await responsePromise
expect(warnings).toEqual([])
expect(response.ok()).toBe(true)
} finally {
dispose()
}
})
test('Prompt execution succeeds without 400 error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
)
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
comfyExpect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
@@ -210,60 +77,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await comfyExpect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await comfyExpect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@widget'] },
@@ -284,7 +100,6 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
@@ -310,10 +125,8 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
await comfyExpect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
@@ -345,87 +158,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
await comfyExpect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
// Pack all interior nodes into a nested subgraph
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
// Verify all proxyWidgets entries resolve
await comfyExpect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(
Number(nodeId)
)
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@widget'] },
@@ -447,12 +182,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await comfyExpect(widgets).toHaveCount(1)
await comfyExpect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
})

View File

@@ -2,19 +2,19 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Subgraph Internal Operations',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph Clipboard Operations', () => {
test('Can copy and paste nodes inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
@@ -22,45 +22,43 @@ test.describe(
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
const nodeId = await comfyPage.page.evaluate(() => {
const nodes = window.app!.canvas.graph!.nodes
return nodes?.[0]?.id || null
})
expect(nodeId).not.toBeNull()
expect(nodesInSubgraph).not.toBeNull()
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
String(nodesInSubgraph)
)
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+c')
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
test.describe('Subgraph History Operations', () => {
test('Can undo and redo operations inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Add a node
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
// Get initial node count
const initialCount = await comfyPage.subgraph.getNodeCount()
// Undo
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
@@ -68,7 +66,6 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
@@ -76,5 +73,5 @@ test.describe(
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
}
)
})
})

View File

@@ -47,25 +47,20 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
@@ -113,29 +108,12 @@ test.describe(
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
@@ -143,7 +121,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -151,57 +129,26 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -211,87 +158,30 @@ test.describe(
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
})
@@ -300,7 +190,7 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
test('Can promote and un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -308,10 +198,9 @@ test.describe(
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler.click('title')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
@@ -360,12 +249,13 @@ test.describe(
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
@@ -373,12 +263,11 @@ test.describe(
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
await ksampler2.click('title')
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
@@ -396,10 +285,8 @@ test.describe(
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
@@ -422,26 +309,16 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
// Navigate into the subgraph (node id 11)
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// The interior CLIPTextEncode node (id 10) should render a textarea
// widget in Vue mode. Right-click it to verify the contextmenu
// event propagates correctly (fix from PR #9840) and shows the
// ComfyUI context menu with "Promote Widget".
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
// Select the node first so the context menu builds correctly
await comfyPage.vueNodes.selectNode('10')
await comfyPage.nextFrame()
// Dispatch a contextmenu event directly on the textarea. A normal
// right-click is intercepted by the z-999 canvas overlay, but the
// Vue WidgetTextarea.vue handler listens on @contextmenu.capture,
// so dispatching the event directly tests the fix from PR #9840.
const textarea = clipNode.locator('textarea')
await expect(textarea).toBeVisible()
await textarea.dispatchEvent('contextmenu', {
@@ -451,8 +328,6 @@ test.describe(
})
await comfyPage.nextFrame()
// The PrimeVue context menu should show "Promote Widget" since
// the node is inside a subgraph (not the root graph).
const promoteEntry = comfyPage.page
.locator('.p-contextmenu')
.locator('text=Promote Widget')
@@ -462,7 +337,7 @@ test.describe(
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
test('Promoted preview nodes render custom content in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -506,11 +381,6 @@ test.describe(
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
@@ -519,14 +389,35 @@ test.describe(
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('filename_prefix')
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
const loadImagePosition = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: loadImagePosition
})
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
await expect(
nodeBody.locator('.lg-node-widgets > div').first()
).toBeVisible()
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
timeout: 30_000
})
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
'$$canvas-image-preview'
)
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
@@ -534,10 +425,6 @@ test.describe(
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
@@ -553,29 +440,12 @@ test.describe(
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
@@ -588,8 +458,11 @@ test.describe(
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
}
expect(editedTextarea).toBe(true)
})
})
@@ -687,6 +560,28 @@ test.describe(
})
.toBeLessThan(initialWidgetCount)
})
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(
() => getPromotedWidgetCount(comfyPage, '2'),
'Primitive widget is restored on load'
)
.toBe(2)
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
const promotedPrimitive = await subgraphNode!.getWidget(1)
await expect
.poll(
() => promotedPrimitive.getValue(),
'Primitive widget is not in a disconnected state'
)
.toBe(0)
})
})
}
)

View File

@@ -1,218 +1,170 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
// Constants
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
// Common selectors
const SELECTORS = {
breadcrumb: '.subgraph-breadcrumb',
domWidget: '.comfy-multiline-input'
} as const
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
await comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.rootGraph.nodes.find(
(candidate) => String(candidate.id) === targetNodeId
)
if (!node || !('subgraph' in node) || !node.subgraph) {
throw new Error(`Subgraph node ${targetNodeId} not found`)
}
test.describe(
'Subgraph Promoted Widget DOM',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
window.app!.canvas.openSubgraph(node.subgraph, node)
}, nodeId)
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
}),
{ timeout: 5_000 }
)
.toBe(true)
}
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
await openSubgraphById(comfyPage, '11')
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test.describe('DOM Widget Navigation and Persistence', () => {
test('DOM widget visibility persists through subgraph navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promoted widget is visible in parent graph
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await subgraphNode.navigateIntoSubgraph()
// Verify widget is visible in subgraph
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
// Navigate back
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Verify widget is still visible
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
})
test('DOM widget content is preserved through navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
await textarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const parentCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const subgraphCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(subgraphCount).toBe(parentCount)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(parentCount)
})
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
test.describe('DOM Cleanup', () => {
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const initialCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(initialCount).toBe(1)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
await subgraphNode.delete()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
const finalCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(finalCount).toBe(0)
})
await openSubgraphById(comfyPage, '11')
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
// Enable new menu for breadcrumb navigation
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.subgraph.removeSlot('input', 'text')
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.subgraph.exitViaBreadcrumb()
const textareaCount = await comfyPage.page
.locator(SELECTORS.domWidget)
.count()
expect(textareaCount).toBe(1)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
// Navigate into subgraph (method now handles retries internally)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.removeSlot('input', 'text')
// Wait for breadcrumb to be visible
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
state: 'visible',
timeout: 5000
})
// Click breadcrumb to navigate back to parent graph
const homeBreadcrumb = comfyPage.page.locator(
'.p-breadcrumb-list > :first-child'
)
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Check that the subgraph node has no widgets after removing the text slot
const widgetCount = await comfyPage.page.evaluate(() => {
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
})
expect(widgetCount).toBe(0)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
test.describe('DOM Positioning', () => {
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const parentCount = await comfyPage.page
.locator(VISIBLE_DOM_WIDGET_SELECTOR)
.count()
expect(parentCount).toBeGreaterThan(1)
const subgraphNodeId = String(subgraphNode.id)
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
expect(await subgraphNode.exists()).toBe(true)
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
await openSubgraphById(comfyPage, '11')
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator
.getByLabel('seed', { exact: true })
.first()
await expect(seedWidget).toBeVisible()
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify widget is inside the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(parentCount)
})
}
)
})
})

View File

@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
name: blueprintName
})
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
await comfyPage.toast.closeToasts(1)
}
@@ -36,7 +36,7 @@ async function searchAndExpectResult(
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.input.fill(searchTerm)
await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({
timeout: 10000
timeout: 10_000
})
}
@@ -49,35 +49,22 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
)
})
test('Can set search aliases on subgraph and find via search', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', {
aliases: 'qwerty,unicorn'
})
const blueprintName = `test-aliases-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
await searchAndExpectResult(comfyPage, 'unicorn', blueprintName)
})
test('Can set description on subgraph', async ({ comfyPage }) => {
await createSubgraphAndNavigateInto(comfyPage)
await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', {
description: 'This is a test description'
})
// Verify the description was set on the subgraph's extra
const description = await comfyPage.page.evaluate(() => {
const subgraph = window['app']!.canvas.subgraph
const subgraph = window.app!.canvas.subgraph
return (subgraph?.extra as Record<string, unknown>)?.BlueprintDescription
})
expect(description).toBe('This is a test description')
})
test('Search aliases persist after publish and reload', async ({
test('Published search aliases remain searchable after reload', async ({
comfyPage
}) => {
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
@@ -89,10 +76,9 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
const blueprintName = `test-persist-${Date.now()}`
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
// Reload the page to ensure aliases are persisted
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(
() => window['app'] && window['app'].extensionManager
() => window.app && window.app.extensionManager
)
await comfyPage.nextFrame()

View File

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

View File

@@ -5,20 +5,18 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import {
expectSlotsWithinBounds,
measureNodeSlotOffsets
} from '@e2e/fixtures/utils/slotBoundsUtil'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
const RENAMED_NAME = 'renamed_slot_name'
const RENAMED_SLOT_NAME = 'renamed_slot_name'
const SECOND_RENAMED_NAME = 'second_renamed_name'
const RENAMED_LABEL = 'my_seed'
// Common selectors
const SELECTORS = {
promptDialog: '.graphdialog input'
} as const
@@ -32,7 +30,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
)
})
test.describe('I/O Slot CRUD', () => {
test.describe('I/O Slot Management', () => {
test('Can add input slots to subgraph', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -83,8 +81,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('input')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -103,8 +99,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
expect(initialCount).toBeGreaterThan(0)
await comfyPage.subgraph.removeSlot('output')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -112,10 +106,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
test.describe('Slot Rename', () => {
test('Can rename I/O slots via right-click context menu', async ({
test('Can rename an input slot from the context menu', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
@@ -129,13 +121,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
@@ -239,26 +228,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
// Use direct pointer event approach to double-click on label
await comfyPage.page.evaluate(() => {
const app = window.app!
const graph = app.canvas.graph
if (!graph || !('inputNode' in graph)) {
if (!graph || !('inputNode' in graph))
throw new Error('Expected to be in subgraph')
}
const input = graph.inputs?.[0]
if (!input?.labelPos) {
if (!input?.labelPos)
throw new Error('Could not get label position for testing')
}
// Use labelPos for more precise clicking on the text
const testX = input.labelPos[0]
const testY = input.labelPos[1]
// Create a minimal mock event with required properties
// Full PointerEvent creation is unnecessary for this test
const leftClickEvent = {
canvasX: testX,
canvasY: testY,
canvasX: input.labelPos[0],
canvasY: input.labelPos[1],
button: 0,
preventDefault: () => {},
stopPropagation: () => {}
@@ -272,7 +252,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
app.canvas.linkConnector
)
// Trigger double-click if pointer has the handler
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(leftClickEvent)
}
@@ -281,25 +260,21 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
const labelClickRenamedName = 'label_click_renamed'
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
await comfyPage.page.keyboard.press('Enter')
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(labelClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
})
})
test.describe('Slot Rename Dialog', () => {
test.describe('Subgraph Slot Rename Dialog', () => {
test('Shows current slot label (not stale) in rename dialog', async ({
comfyPage
}) => {
@@ -308,39 +283,27 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial slot label
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
if (initialInputLabel === null) {
throw new Error(
'Expected subgraph to have an input slot label for rightClickInputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the rename worked
const afterFirstRename = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph))
@@ -352,42 +315,29 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
displayName: slot?.displayName || slot?.label || slot?.name || null
}
})
expect(afterFirstRename.label).toBe(RENAMED_NAME)
expect(afterFirstRename.label).toBe(RENAMED_SLOT_NAME)
// Now rename again - this is where the bug would show
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Verify the second rename worked
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
})
@@ -400,173 +350,193 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get initial output slot label
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
if (initialOutputLabel === null) {
throw new Error(
'Expected subgraph to have an output slot label for rightClickOutputSlot'
)
}
// First rename
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
// Clear and enter new name
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
await comfyPage.page.keyboard.press('Enter')
// Wait for dialog to close
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'hidden'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// Force re-render
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
// Now rename again to check for stale content
// We need to use the index-based approach since the method looks for slot.name
await comfyPage.subgraph.rightClickOutputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
state: 'visible'
})
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(
SELECTORS.promptDialog
)
// This should show the current label (RENAMED_NAME), not the original name
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
})
})
test.describe('Slot Rename Propagation', () => {
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe('Subgraph input slot rename propagation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNode).toBeVisible()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await comfyExpect(seedWidget).toBeVisible()
const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
// Verify widget is in the node body, not the header
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const seedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
const inputs = (graph as { inputs?: Array<{ name: string }> }).inputs
return (
inputs?.find((input) => input.name.includes('seed'))?.name ?? null
)
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = SELECTORS.promptDialog
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await comfyExpect(sgNodeAfter).toBeVisible()
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(subgraphNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const updatedLabel = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
const widget = node.widgets?.find((entry: { name: string }) =>
entry.name.includes('seed')
)
return w?.label || w?.name || null
return widget?.label || widget?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await comfyExpect(seedWidgetAfter).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
exact: true
})
await expect(seedWidgetAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(
subgraphNodeAfter,
seedWidgetAfter
)
})
})
test.describe('Compressed target_slot', () => {
test('Can create widget from link with compressed target_slot', async ({
test.describe('Subgraph promoted widget-input slot position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
'subgraphs/subgraph-with-promoted-text-widget'
)
const step = await comfyPage.page.evaluate(() => {
return window.app!.graph!.nodes[0].widgets![0].options.step
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Two frames needed: first renders slot changes, second stabilizes layout
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(input: { type: string }) => input.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
expect(step).toBe(10)
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
await comfyPage.page.fill(SELECTORS.promptDialog, '')
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
expect(after!.widgetName).toBe(before!.widgetName)
})
})
test.describe('Slot Alignment', () => {
/**
* Regression test for link misalignment on SubgraphNodes when loading
* workflows with workflowRendererVersion: "LG".
*
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
* slot offsets. The fix uses DOM-relative measurement instead.
*/
test.describe('Subgraph slot alignment after LG layout scale', () => {
test('slot positions stay within node bounds after loading LG workflow', async ({
comfyPage
}) => {
@@ -610,91 +580,4 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
}
})
})
test.describe('Promoted Slot Position', () => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = SELECTORS.promptDialog
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await SubgraphHelper.getTextSlotPosition(
comfyPage.page,
'11'
)
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
})
})

View File

@@ -0,0 +1,290 @@
import { expect } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
[]
)
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
const desktopCard = comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('desktop-hidden')
)
await expect(desktopCard).toHaveCount(0)
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
const wanDesktopCard = comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('wan-desktop')
)
await expect(wanDesktopCard).toHaveCount(0)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 2 of 3 templates/i
)
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('cloud-visible')
)
).toBeVisible()
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('desktop-leak-check')
)
).toHaveCount(0)
await expect(
comfyPage.templatesDialog.root.getByTestId(
TestIds.templates.workflowCard('local-leak-check')
)
).toHaveCount(0)
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 3 of 3 templates/i
)
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
const clearButton = comfyPage.templatesDialog.root.getByRole('button', {
name: /Clear Filters/i
})
await clearButton.click()
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
/Showing 2 of 2 templates/i
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,6 +2,10 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const SHOW_ADVANCED_INPUTS = 'Show advanced inputs'
const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -21,15 +25,11 @@ test.describe('Advanced Widget Visibility', () => {
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getNode(comfyPage: ComfyPage) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
function getWidgets(comfyPage: ComfyPage) {
return getNode(comfyPage).locator('.lg-node-widget')
}
@@ -51,7 +51,7 @@ test.describe('Advanced Widget Visibility', () => {
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
@@ -63,20 +63,41 @@ test.describe('Advanced Widget Visibility', () => {
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await node.getByText(SHOW_ADVANCED_INPUTS).click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await node.getByText(HIDE_ADVANCED_INPUTS).click()
await expect(widgets).toHaveCount(2)
})
test('should hide advanced footer button while collapsed', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS)
const vueNode =
await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux')
await expect(showAdvancedButton).toBeVisible()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeHidden()
await vueNode.toggleCollapse()
await comfyPage.nextFrame()
await expect(showAdvancedButton).toBeVisible()
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
@@ -97,6 +118,6 @@ test.describe('Advanced Widget Visibility', () => {
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden()
})
})

View File

@@ -0,0 +1,66 @@
---
globs:
- 'src/components/**/*.vue'
- 'src/views/**/*.vue'
---
# Comfy Design Standards
Applies when implementing or modifying user-facing components and views.
## Before Implementing UI Changes
Consult the **Comfy Design Standards** Figma file to ensure your changes follow the agreed-upon design principles. Use the Figma MCP tool to fetch the current standards:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '0:1' })
```
The Figma file is the single source of truth. Always fetch it live — do not rely on cached assumptions.
> **Note:** The Figma MCP is read-only. It cannot detect changes or diffs between versions. Always fetch the latest state before implementing.
### Key Sections
| Section | Node ID | When to consult |
| -------------------- | --------- | ----------------------------------------------------------------------------- |
| Hover States | `1:2` | Adding/modifying interactive elements (buttons, inputs, links, nav items) |
| Click Targets | `4:243` | Adding clickable elements, especially small ones (icons, handles, connectors) |
| Affordances | `15:2202` | Any interactive element — ensuring visual feedback on interaction |
| Feedback | `15:2334` | User actions that need confirmation, success/error states |
| Slips and How to Fix | `15:2337` | Error prevention, undo patterns, destructive actions |
| Design Pillars | `15:2340` | New features, architectural UI decisions |
| The User | `16:2348` | User flows, onboarding, accessibility |
Fetch the specific section relevant to your task:
```javascript
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '<node-id>' })
```
## Figma Component Reference
The Figma file contains component specifications. When implementing these components, fetch details to match the design:
| Component | Component Set ID |
| ----------------- | ---------------- |
| Button/Default | `4:314` |
| Search | `4:2366` |
| Base Node Example | `4:4739` |
## Figma Token Translation Rules
When translating Figma design tokens into code:
- **Skip `-hover` and `-selected` suffixed tokens.** These states exist in Figma only for prototype demonstrations. On the frontend, hover and selected states must be derived programmatically (e.g., via `color-mix()` or Tailwind modifier classes like `hover:`).
- **Color tier system:** Figma uses a tiered color hierarchy:
- **Base** — default surface/background colors
- **Secondary** — elevated surfaces (e.g., sidebars, cards)
- **Tertiary** — elements on modal panels (one shade lighter than base)
- Map Figma token names directly to Tailwind 4 semantic tokens — never hardcode hex values.
## Integration with Codebase
- Map Figma color values to Tailwind 4 semantic tokens — never hardcode hex values
- Use `cn()` from `@/utils/tailwindUtil` for conditional class merging
- Use the `dark:` avoidance rule from AGENTS.md — semantic tokens handle both themes

View File

@@ -1,6 +1,7 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -33,11 +34,9 @@ const config: KnipConfig = {
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
'src/styles/global.css'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
}
},
ignoreBinaries: ['python3'],
@@ -54,8 +53,6 @@ const config: KnipConfig = {
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/ingest-types/src/zod.gen.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR

View File

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

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-oxlint: 1.59.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
@@ -89,14 +89,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^6.3.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -0,0 +1,10 @@
/** Padding applied around the selection bounding rect (both screen + canvas). */
export const SELECTION_BOUNDS_PADDING = 10
/** Rectangle expressed in canvas-world coordinates. */
export interface CanvasRect {
x: number
y: number
w: number
h: number
}

View File

@@ -1,5 +1,8 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
@@ -8,8 +11,6 @@ import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
@@ -114,8 +115,9 @@ function createWrapper({
}
})
return mount(TopMenuSection, {
attachTo,
const user = userEvent.setup()
const renderOptions: Record<string, unknown> = {
global: {
plugins: [pinia, i18n],
stubs: {
@@ -128,7 +130,8 @@ function createWrapper({
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
template:
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
},
...stubs
},
@@ -136,15 +139,23 @@ function createWrapper({
tooltip: () => {}
}
}
})
}
if (attachTo) {
renderOptions.container = attachTo.appendChild(
document.createElement('div')
)
}
const { container, unmount } = render(TopMenuSection, renderOptions)
return { container, unmount, user }
}
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
function getLegacyCommandsContainer(container: Element): HTMLElement {
const legacyContainer = container.querySelector(
'[data-testid="legacy-topbar-container"]'
).element
)
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
@@ -201,9 +212,11 @@ describe('TopMenuSection', () => {
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
@@ -215,24 +228,24 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
@@ -242,19 +255,15 @@ describe('TopMenuSection', () => {
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
const queueButton = screen.getByTestId('queue-overlay-toggle')
expect(queueButton.textContent).toContain('3 active')
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
@@ -263,16 +272,12 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
@@ -281,10 +286,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
@@ -297,10 +302,10 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
await user.click(screen.getByTestId('queue-overlay-toggle'))
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
@@ -311,14 +316,14 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const { user } = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
const toggleButton = screen.getByTestId('queue-overlay-toggle')
await toggleButton.trigger('click')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await toggleButton.trigger('click')
await user.click(toggleButton)
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
@@ -341,39 +346,39 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
container.querySelector('queue-inline-progress-summary-stub')
).not.toBeNull()
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
container.querySelector('queue-inline-progress-summary-stub')
).toBeNull()
})
it('teleports inline progress summary when actionbar is floating', async () => {
@@ -387,7 +392,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
const { unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -401,7 +406,7 @@ describe('TopMenuSection', () => {
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
unmount()
actionbarTarget.remove()
}
})
@@ -424,36 +429,36 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
const { container } = createWrapper({ pinia })
await nextTick()
const html = wrapper.html()
const html = container.innerHTML
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
@@ -477,7 +482,7 @@ describe('TopMenuSection', () => {
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body,
stubs: {
@@ -493,47 +498,49 @@ describe('TopMenuSection', () => {
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
wrapper
.findComponent({ name: 'QueueNotificationBannerHost' })
.exists()
).toBe(true)
container.querySelector('queue-notification-banner-host-stub')
).not.toBeNull()
} finally {
wrapper.unmount()
unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
const { container } = createWrapper()
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const { container } = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
const menuEl = container.querySelector('[data-testid="context-menu"]')
const model = JSON.parse(
menuEl?.getAttribute('data-model') ?? '[]'
) as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
const { container } = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
expect(container.querySelector('span.bg-red-500')).toBeNull()
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
@@ -555,15 +562,19 @@ describe('TopMenuSection', () => {
return undefined
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
const { container, unmount } = createWrapper({
pinia,
attachTo: document.body
})
try {
await nextTick()
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const actionbarContainer = container.querySelector('.actionbar-container')
expect(actionbarContainer).not.toBeNull()
expect(actionbarContainer!.classList).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(wrapper)
const legacyContainer = getLegacyCommandsContainer(container)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
@@ -594,9 +605,9 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
expect(actionbarContainer!.classList).toContain('px-2')
} finally {
wrapper.unmount()
unmount()
vi.unstubAllGlobals()
}
})

View File

@@ -1,9 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
@@ -12,7 +10,7 @@ vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
'<div data-testid="shortcuts-list">{{ JSON.stringify(subcategories) }}</div>'
}
}))
@@ -56,25 +54,34 @@ describe('EssentialsPanel', () => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
it('should render ShortcutsList with essentials commands', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
expect(screen.getByTestId('shortcuts-list')).toBeTruthy()
})
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
it('should categorize commands into subcategories', async () => {
const { default: EssentialsPanel } =
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
render(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
const el = screen.getByTestId('shortcuts-list')
const subcategories = JSON.parse(el.textContent ?? '{}')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
expect(subcategories.workflow).toContainEqual(
expect.objectContaining({ id: 'Workflow.New' })
)
expect(subcategories.node).toContainEqual(
expect.objectContaining({ id: 'Node.Add' })
)
expect(subcategories.queue).toContainEqual(
expect.objectContaining({ id: 'Queue.Clear' })
)
})
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
@@ -64,36 +65,31 @@ describe('ShortcutsList', () => {
}
it('should render shortcuts organized by subcategories', () => {
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
expect(screen.getByText('Workflow')).toBeInTheDocument()
expect(screen.getByText('Node')).toBeInTheDocument()
expect(screen.getByText('Queue')).toBeInTheDocument()
expect(screen.getByText('New Blank Workflow')).toBeInTheDocument()
})
it('should format keyboard shortcuts correctly', () => {
const wrapper = mount(ShortcutsList, {
const { container } = render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
const text = container.textContent!
expect(text).toContain('Ctrl')
expect(text).toContain('n')
expect(text).toContain('Shift')
expect(text).toContain('a')
expect(text).toContain('c')
})
it('should filter out commands without keybindings', () => {
@@ -107,9 +103,8 @@ describe('ShortcutsList', () => {
} as ComfyCommandImpl
]
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
@@ -117,7 +112,7 @@ describe('ShortcutsList', () => {
}
})
expect(wrapper.text()).not.toContain('No Keybinding')
expect(screen.queryByText('No Keybinding')).not.toBeInTheDocument()
})
it('should handle special key formatting', () => {
@@ -132,16 +127,15 @@ describe('ShortcutsList', () => {
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {
const { container } = render(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = wrapper.text()
const text = container.textContent!
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
@@ -150,15 +144,14 @@ describe('ShortcutsList', () => {
})
it('should use fallback subcategory titles', () => {
const wrapper = mount(ShortcutsList, {
render(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(wrapper.text()).toContain('unknown')
expect(screen.getByText('unknown')).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,9 @@
/* eslint-disable testing-library/no-node-access */
/* eslint-disable testing-library/prefer-user-event */
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -67,9 +68,10 @@ vi.mock('@/platform/distribution/types', () => ({
}))
// Mock clipboard API
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
writeText: mockWriteText
},
configurable: true
})
@@ -87,8 +89,9 @@ const i18n = createI18n({
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
function renderBaseTerminal(props: Record<string, unknown> = {}) {
return render(BaseTerminal, {
props,
global: {
plugins: [
createTestingPinia({
@@ -107,68 +110,60 @@ const mountBaseTerminal = () => {
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
const onCreated = vi.fn()
renderBaseTerminal({ onCreated })
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
expect(onCreated).toHaveBeenCalled()
expect(onCreated.mock.calls[0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
const onUnmounted = vi.fn()
const { unmount } = renderBaseTerminal({ onUnmounted })
unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
expect(onUnmounted).toHaveBeenCalled()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
it('button exists and has correct initial state', () => {
renderBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
const button = screen.getByRole('button')
expect(button).toHaveClass('opacity-0', 'pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-label', 'Copy selection')
})
it('copies selected text when selection exists', async () => {
@@ -176,16 +171,17 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockWriteText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
@@ -196,16 +192,17 @@ describe('BaseTerminal', () => {
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockWriteText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
@@ -213,15 +210,16 @@ describe('BaseTerminal', () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
const { container } = renderBaseTerminal()
await wrapper.trigger('mouseenter')
await fireEvent.mouseEnter(container.firstElementChild!)
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
const button = screen.getByRole('button')
await fireEvent.click(button)
await nextTick()
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
expect(mockWriteText).not.toHaveBeenCalled()
})
})

View File

@@ -1,44 +1,44 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
render(Badge, { props: { label: 'NEW' } })
expect(screen.getByText('NEW')).toBeInTheDocument()
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
render(Badge, { props: { label: 5 } })
expect(screen.getByText('5')).toBeInTheDocument()
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
const { container } = render(Badge)
// eslint-disable-next-line testing-library/no-node-access -- dot badge has no text/role to query
expect(container.firstElementChild).toHaveClass('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
render(Badge, { props: { label: 'NEW' } })
const el = screen.getByText('NEW')
expect(el).toHaveClass('font-semibold')
expect(el).toHaveClass('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
render(Badge, { props: { label: '3', variant: 'circle' } })
expect(screen.getByText('3')).toHaveClass('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
render(Badge, { props: { label: 'Test', class: 'ml-2' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('ml-2')
expect(el).toHaveClass('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
@@ -58,12 +58,10 @@ describe('Badge', () => {
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
render(Badge, { props: { label: 'Test', severity: 'danger' } })
const el = screen.getByText('Test')
expect(el).toHaveClass('text-white')
expect(el).toHaveClass('text-3xs')
})
})
})

View File

@@ -1,22 +1,22 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import MarqueeLine from './MarqueeLine.vue'
describe(MarqueeLine, () => {
it('renders slot content', () => {
const wrapper = mount(MarqueeLine, {
render(MarqueeLine, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('renders content inside a span within the container', () => {
const wrapper = mount(MarqueeLine, {
render(MarqueeLine, {
slots: { default: 'Test Text' }
})
const span = wrapper.find('span')
expect(span.exists()).toBe(true)
expect(span.text()).toBe('Test Text')
const el = screen.getByText('Test Text')
expect(el.tagName).toBe('SPAN')
})
})

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import NotificationPopup from './NotificationPopup.vue'
@@ -13,13 +13,11 @@ const i18n = createI18n({
}
})
function mountPopup(
props: ComponentProps<typeof NotificationPopup> = {
title: 'Test'
},
function renderPopup(
props: { title: string; [key: string]: unknown } = { title: 'Test' },
slots: Record<string, string> = {}
) {
return mount(NotificationPopup, {
return render(NotificationPopup, {
global: { plugins: [i18n] },
props,
slots
@@ -28,51 +26,58 @@ function mountPopup(
describe('NotificationPopup', () => {
it('renders title', () => {
const wrapper = mountPopup({ title: 'Hello World' })
expect(wrapper.text()).toContain('Hello World')
renderPopup({ title: 'Hello World' })
expect(screen.getByRole('status')).toHaveTextContent('Hello World')
})
it('has role="status" for accessibility', () => {
const wrapper = mountPopup()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
renderPopup()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders subtitle when provided', () => {
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(wrapper.text()).toContain('v1.2.3')
renderPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(screen.getByRole('status')).toHaveTextContent('v1.2.3')
})
it('renders icon when provided', () => {
const wrapper = mountPopup({
const { container } = renderPopup({
title: 'T',
icon: 'icon-[lucide--rocket]'
})
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('i.icon-\\[lucide--rocket\\]')
expect(icon).toBeInTheDocument()
})
it('emits close when close button clicked', async () => {
const wrapper = mountPopup({ title: 'T', showClose: true })
await wrapper.find('[aria-label="Close"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
const user = userEvent.setup()
const closeSpy = vi.fn()
renderPopup({ title: 'T', showClose: true, onClose: closeSpy })
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(closeSpy).toHaveBeenCalledOnce()
})
it('renders default slot content', () => {
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
expect(wrapper.text()).toContain('Body text here')
renderPopup({ title: 'T' }, { default: 'Body text here' })
expect(screen.getByRole('status')).toHaveTextContent('Body text here')
})
it('renders footer slots', () => {
const wrapper = mountPopup(
renderPopup(
{ title: 'T' },
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
)
expect(wrapper.text()).toContain('Left side')
expect(wrapper.text()).toContain('Right side')
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Left side')
expect(status).toHaveTextContent('Right side')
})
it('positions bottom-right when specified', () => {
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
const root = wrapper.find('[role="status"]')
expect(root.attributes('data-position')).toBe('bottom-right')
renderPopup({ title: 'T', position: 'bottom-right' })
expect(screen.getByRole('status')).toHaveAttribute(
'data-position',
'bottom-right'
)
})
})

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,8 @@ function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
describe(TextTicker, () => {
let rafCallbacks: ((time: number) => void)[]
let wrapper: ReturnType<typeof mount>
let user: ReturnType<typeof userEvent.setup>
let cleanup: (() => void) | undefined
beforeEach(() => {
vi.useFakeTimers()
@@ -23,32 +25,35 @@ describe(TextTicker, () => {
return rafCallbacks.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
})
afterEach(() => {
wrapper?.unmount()
cleanup?.()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders slot content', () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
cleanup = unmount
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('scrolls on hover after delay', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = wrapper.element as HTMLElement
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
expect(rafCallbacks.length).toBe(0)
@@ -62,19 +67,21 @@ describe(TextTicker, () => {
})
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
mockScrollWidth(wrapper.element as HTMLElement, 300)
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(200)
await wrapper.trigger('mouseleave')
await user.unhover(el)
await nextTick()
vi.advanceTimersByTime(350)
@@ -83,16 +90,17 @@ describe(TextTicker, () => {
})
it('resets scroll position on mouse leave', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
cleanup = unmount
const el = wrapper.element as HTMLElement
const el = screen.getByText('Very long text that overflows')
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
@@ -100,19 +108,22 @@ describe(TextTicker, () => {
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
await wrapper.trigger('mouseleave')
await user.unhover(el)
await nextTick()
expect(el.scrollLeft).toBe(0)
})
it('does not scroll when content fits', async () => {
wrapper = mount(TextTicker, {
const { unmount } = render(TextTicker, {
slots: { default: 'Short' }
})
cleanup = unmount
const el = screen.getByText('Short')
await nextTick()
await wrapper.trigger('mouseenter')
await user.hover(el)
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()

View File

@@ -1,8 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
import TextTickerMultiLine from './TextTickerMultiLine.vue'
type Callback = () => void
@@ -41,23 +40,38 @@ function mockElementSize(
}
describe(TextTickerMultiLine, () => {
let wrapper: ReturnType<typeof mount>
let unmountFn: () => void
afterEach(() => {
wrapper?.unmount()
unmountFn?.()
resizeCallbacks.length = 0
mutationCallbacks.length = 0
})
function mountComponent(text: string) {
wrapper = mount(TextTickerMultiLine, {
function renderComponent(text: string) {
const result = render(TextTickerMultiLine, {
slots: { default: text }
})
return wrapper
unmountFn = result.unmount
return {
...result,
container: result.container as HTMLElement
}
}
function getMeasureEl(): HTMLElement {
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
function getMeasureEl(container: HTMLElement): HTMLElement {
// eslint-disable-next-line testing-library/no-node-access
return container.querySelector('[aria-hidden="true"]') as HTMLElement
}
function getVisibleLines(container: HTMLElement): HTMLElement[] {
/* eslint-disable testing-library/no-node-access */
return Array.from(
container.querySelectorAll<HTMLElement>(
'div.overflow-hidden:not([aria-hidden])'
)
)
/* eslint-enable testing-library/no-node-access */
}
async function triggerSplitLines() {
@@ -66,40 +80,42 @@ describe(TextTickerMultiLine, () => {
}
it('renders slot content', () => {
mountComponent('Load Checkpoint')
expect(wrapper.text()).toContain('Load Checkpoint')
renderComponent('Load Checkpoint')
expect(
screen.getAllByText('Load Checkpoint').length
).toBeGreaterThanOrEqual(1)
})
it('renders a single MarqueeLine when text fits', async () => {
mountComponent('Short')
mockElementSize(getMeasureEl(), 200, 100)
it('renders a single line when text fits', async () => {
const { container } = renderComponent('Short')
mockElementSize(getMeasureEl(container), 200, 100)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
expect(getVisibleLines(container)).toHaveLength(1)
})
it('renders two MarqueeLines when text overflows', async () => {
mountComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(), 100, 300)
it('renders two lines when text overflows', async () => {
const { container } = renderComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(container), 100, 300)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
expect(getVisibleLines(container)).toHaveLength(2)
})
it('splits text at word boundary when overflowing', async () => {
mountComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(), 100, 200)
const { container } = renderComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(container), 100, 200)
await triggerSplitLines()
const lines = wrapper.findAllComponents(MarqueeLine)
expect(lines[0].text()).toBe('Load')
expect(lines[1].text()).toBe('Checkpoint Loader')
const lines = getVisibleLines(container)
expect(lines[0].textContent).toBe('Load')
expect(lines[1].textContent).toBe('Checkpoint Loader')
})
it('has hidden measurement element with aria-hidden', () => {
mountComponent('Test')
const measureEl = wrapper.find('[aria-hidden="true"]')
expect(measureEl.exists()).toBe(true)
expect(measureEl.classes()).toContain('invisible')
const { container } = renderComponent('Test')
const measureEl = getMeasureEl(container)
expect(measureEl).toBeInTheDocument()
expect(measureEl).toHaveClass('invisible')
})
})

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