Compare commits

...

31 Commits

Author SHA1 Message Date
Glary-Bot
33a9ecdfa2 docs(LGraphNode): note that await result is load-bearing
Per Christian's review (PR #11856 comment 3177815230): the trailing
'await result' looks redundant since result was already inspected, but
removing it would break Vue's callWithAsyncErrorHandling — async
onDragDrop rejections would become unhandled promise rejections instead
of routing through app.config.errorHandler.

Add a one-line comment to deter future deletion.
2026-05-03 08:05:41 +00:00
Terry Jia
95d2a5b560 [backport core/1.43] fix: stop duplicate node creation when dropping image on Vue nodes (#11541)
Manual backport of #11541 to `core/1.43`.

Cherry-picked merge commit `b23283144`.

## Conflict resolution

- `src/composables/node/useNodeDragAndDrop.ts`: `core/1.43` had the older
  inline `node.onDragDrop = async function (e: DragEvent) { ... }` form,
  while #11541 refactored it to a named `installedDragDrop` const +
  cleanup via `useChainCallback` on `onRemoved`. Took #11541's full
  refactored version (`useChainCallback` already exists on `core/1.43`).
  The signature change adds an optional `claimEvent = false` parameter,
  which is backward-compatible with existing callers.
- `src/composables/node/useNodeDragAndDrop.test.ts`: PR #11541 modified
  this test file but the file was added on `main` by unrelated PR #11417
  (`fix: reset file input value...`) which has not been backported.
  Dropped the test file from this backport — runtime fix is intact.
- `src/renderer/extensions/vueNodes/components/LGraphNode.{vue,test.ts}`,
  `src/types/litegraph-augmentation.d.ts`: auto-merged cleanly.

## Validation

- `pnpm typecheck` (run after stack)

---

## Summary
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.

Updated onDragDrop to take an optional claimEvent flag — when true, it
calls preventDefault()/stopPropagation() synchronously inside the
handler, only after the sync acceptance check passes (valid files /
same-origin URI), and before any await.
The Vue node now just calls await node.onDragDrop(event, true).
Rejected payloads (cross-origin URI, files filtered out, no valid files)
skip the claim and bubble to the document fallback as before. The
remaining edge case is async URI fetch failures, which we can't
sync-detect without speculatively claiming.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/d79a5101-370b-4873-8365-5f9ce188731b

after

https://github.com/user-attachments/assets/8b787474-eab9-4060-8146-c4d8bb24ff9f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11541-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes-34a6d73d36508113b153e31768602933)
by [Unito](https://www.unito.io)

## Backport-only compatibility fix

Per code review: the upstream PR removed the legacy `handled === true`
sync-return check in `LGraphNode.vue`'s `handleDrop()`, replacing it
with `await node.onDragDrop(event, true)`. On `main` that's safe
because all in-repo `onDragDrop` handlers participate in the new
`claimEvent` flag. On `core/1.43`, however, this is a public LiteGraph
extension callback — 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 aims to fix).

Restored the legacy `handled === true` synchronous-claim path in
`handleDrop()` while keeping the new `claimEvent=true` call, so:
- 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)
2026-05-03 04:38:21 +00: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
305 changed files with 18650 additions and 8046 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

@@ -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

@@ -318,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

@@ -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

@@ -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

@@ -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'
@@ -114,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,
@@ -188,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

@@ -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',
@@ -79,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',

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
}

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

@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
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
}) => {

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,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

@@ -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

@@ -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

@@ -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.16",
"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')
})
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -92,7 +93,7 @@ describe('TreeExplorerV2Node', () => {
}
}
function mountComponent(
function renderComponent(
props: Record<string, unknown> = {},
options: {
provide?: Record<string, unknown>
@@ -100,68 +101,76 @@ describe('TreeExplorerV2Node', () => {
} = {}
) {
const treeItemStub = options.treeItemStub ?? createTreeItemStub()
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
},
provide: {
...options.provide
}
const onNodeClick = vi.fn()
const { container } = render(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }
},
props: {
item: createMockItem('node'),
...props
provide: {
...options.provide
}
}),
treeItemStub
}
},
props: {
item: createMockItem('node'),
onNodeClick,
...props
}
})
return { container, treeItemStub, onNodeClick }
}
function getTreeNode(container: Element) {
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
return container.querySelector('div.group\\/tree-node')! as HTMLElement
}
describe('handleClick', () => {
it('emits nodeClick event when clicked', async () => {
const { wrapper } = mountComponent({
const user = userEvent.setup()
const { container, onNodeClick } = renderComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
expect(onNodeClick).toHaveBeenCalled()
expect(onNodeClick.mock.calls[0][0]).toMatchObject({
type: 'node',
label: 'Test Label'
})
})
it('calls handleToggle for folder items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
const { container, onNodeClick } = renderComponent(
{ item: createMockItem('folder') },
{ treeItemStub }
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('click')
const folderDiv = getTreeNode(container)
await user.click(folderDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(onNodeClick).toHaveBeenCalled()
expect(treeItemStub.handleToggle).toHaveBeenCalled()
})
it('does not call handleToggle for node items', async () => {
const user = userEvent.setup()
const treeItemStub = createTreeItemStub()
const { wrapper } = mountComponent(
const { container, onNodeClick } = renderComponent(
{ item: createMockItem('node') },
{ treeItemStub }
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('click')
const nodeDiv = getTreeNode(container)
await user.click(nodeDiv)
expect(wrapper.emitted('nodeClick')).toBeTruthy()
expect(onNodeClick).toHaveBeenCalled()
expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
})
})
@@ -171,7 +180,7 @@ describe('TreeExplorerV2Node', () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { wrapper } = mountComponent(
const { container } = renderComponent(
{ item: nodeItem },
{
provide: {
@@ -180,8 +189,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('contextmenu')
const nodeDiv = getTreeNode(container)
await fireEvent.contextMenu(nodeDiv)
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
@@ -193,7 +202,7 @@ describe('TreeExplorerV2Node', () => {
label: 'Stale'
} as RenderedTreeExplorerNode)
const { wrapper } = mountComponent(
const { container } = renderComponent(
{ item: createMockItem('folder') },
{
provide: {
@@ -202,8 +211,8 @@ describe('TreeExplorerV2Node', () => {
}
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('contextmenu')
const folderDiv = getTreeNode(container)
await fireEvent.contextMenu(folderDiv)
expect(contextMenuNode.value).toBeNull()
})
@@ -216,47 +225,53 @@ describe('TreeExplorerV2Node', () => {
it('shows delete button for user blueprints', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it('hides delete button for non-blueprint nodes', () => {
mockIsUserBlueprint.mockReturnValue(false)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'KSampler' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
expect(
screen.queryByRole('button', { name: 'Delete' })
).not.toBeInTheDocument()
})
it('always shows bookmark button', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
expect(
screen.getByRole('button', { name: 'icon.bookmark' })
).toBeInTheDocument()
})
it('calls deleteBlueprint when delete button is clicked', async () => {
const user = userEvent.setup()
mockIsUserBlueprint.mockReturnValue(true)
const nodeName = 'SubgraphBlueprint.test'
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', {
data: { name: nodeName }
})
})
await wrapper.find('[aria-label="Delete"]').trigger('click')
const deleteButton = screen.getByRole('button', { name: 'Delete' })
await user.click(deleteButton)
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
})
@@ -264,40 +279,47 @@ describe('TreeExplorerV2Node', () => {
describe('rendering', () => {
it('renders node icon for node type', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node')
})
expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('i.icon-\\[comfy--node\\]')).toBeTruthy()
})
it('renders folder icon for folder type', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
})
expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--folder\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
it('renders label text', () => {
const { wrapper } = mountComponent({
renderComponent({
item: createMockItem('node', { label: 'My Node' })
})
expect(wrapper.text()).toContain('My Node')
expect(screen.getByText('My Node')).toBeInTheDocument()
})
it('renders chevron for folder with children', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: {
...createMockItem('folder'),
hasChildren: true
}
})
expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
true
)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(
container.querySelector('i.icon-\\[lucide--chevron-down\\]')
).toBeTruthy()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
})
@@ -307,75 +329,75 @@ describe('TreeExplorerV2Node', () => {
})
it('sets draggable attribute on node items', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node')
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
expect(nodeDiv.attributes('draggable')).toBe('true')
const nodeDiv = getTreeNode(container)
expect(nodeDiv.getAttribute('draggable')).toBe('true')
})
it('does not set draggable on folder items', () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
expect(folderDiv.attributes('draggable')).toBeUndefined()
const folderDiv = getTreeNode(container)
expect(folderDiv.getAttribute('draggable')).toBeNull()
})
it('calls startDrag with native mode on dragstart', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
await nodeDiv.trigger('dragstart')
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
})
it('does not call startDrag for folder items on dragstart', async () => {
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('folder')
})
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('dragstart')
const folderDiv = getTreeNode(container)
await fireEvent.dragStart(folderDiv)
expect(mockStartDrag).not.toHaveBeenCalled()
})
it('calls handleNativeDrop on dragend with drop coordinates', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
const nodeDiv = getTreeNode(container)
await nodeDiv.trigger('dragstart')
await fireEvent.dragStart(nodeDiv)
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('calls handleNativeDrop regardless of dropEffect', async () => {
const mockData = { name: 'TestNode' }
const { wrapper } = mountComponent({
const { container } = renderComponent({
item: createMockItem('node', { data: mockData })
})
const nodeDiv = wrapper.find('div.group\\/tree-node')
const nodeDiv = getTreeNode(container)
await nodeDiv.trigger('dragstart')
await fireEvent.dragStart(nodeDiv)
mockHandleNativeDrop.mockClear()
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
@@ -385,8 +407,8 @@ describe('TreeExplorerV2Node', () => {
value: { dropEffect: 'none' }
})
await nodeDiv.element.dispatchEvent(dragEndEvent)
await wrapper.vm.$nextTick()
nodeDiv.dispatchEvent(dragEndEvent)
await nextTick()
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})

View File

@@ -26,28 +26,35 @@
</slot>
</span>
<div class="flex shrink-0 items-center gap-0.5">
<button
<Button
v-if="isUserBlueprint"
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
variant="muted-textonly"
size="icon-sm"
class="opacity-0 group-hover/tree-node:opacity-100"
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
<i class="icon-[lucide--trash-2] bg-destructive-background" />
</Button>
<Button
v-if="isUserBlueprint"
variant="muted-textonly"
size="icon-sm"
class="opacity-0 group-hover/tree-node:opacity-100"
:aria-label="$t('g.edit')"
@click.stop="editBlueprint"
>
<i class="icon-[lucide--square-pen]" />
</Button>
<Button
variant="muted-textonly"
size="icon-sm"
class="opacity-0 group-hover/tree-node:opacity-100"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
</Button>
</div>
</div>
@@ -100,6 +107,7 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import Button from '@/components/ui/button/Button.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -114,9 +122,6 @@ defineOptions({
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
}>()
@@ -154,6 +159,13 @@ function deleteBlueprint() {
void subgraphStore.deleteBlueprint(nodeDef.value.name)
}
}
const editBlueprint = async () => {
if (!nodeDef.value)
throw new Error(
'Failed to edit subgraph blueprint lacking backing node data'
)
await useSubgraphStore().editBlueprint(nodeDef.value.name)
}
const {
previewRef,

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
@@ -46,7 +46,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -60,16 +60,14 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.getAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
@@ -79,7 +77,7 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -94,7 +92,7 @@ describe('VirtualGrid', () => {
return null
}
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
@@ -104,8 +102,6 @@ describe('VirtualGrid', () => {
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
@@ -114,28 +110,29 @@ describe('VirtualGrid', () => {
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const { container } = render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const gridElement = container.querySelector(
'[style*="display: grid"]'
) as HTMLElement
expect(gridElement).not.toBeNull()
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(2, minmax(0, 1fr))'
)
})
it('renders empty when no items provided', async () => {
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items: [],
gridStyle: defaultGridStyle
@@ -149,10 +146,8 @@ describe('VirtualGrid', () => {
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.queryAllByText(/^Item \d+$/)
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
@@ -161,7 +156,9 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: {
@@ -171,19 +168,20 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1
bufferRows: 1,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
expect(wrapper.emitted('approach-end')).toBeUndefined()
expect(onApproachEnd).not.toHaveBeenCalled()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
@@ -195,9 +193,7 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 1680
await nextTick()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
expect(onApproachEnd).toHaveBeenCalled()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
@@ -208,7 +204,9 @@ describe('VirtualGrid', () => {
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
const onApproachEnd = vi.fn()
render(VirtualGrid, {
props: {
items,
gridStyle: {
@@ -218,14 +216,15 @@ describe('VirtualGrid', () => {
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1
bufferRows: 1,
onApproachEnd
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
@@ -237,9 +236,7 @@ describe('VirtualGrid', () => {
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
expect(onApproachEnd).not.toHaveBeenCalled()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
@@ -248,7 +245,7 @@ describe('VirtualGrid', () => {
mockedScrollY.value = 0
const items = createItems(20)
const wrapper = mount(VirtualGrid<TestItem>, {
render(VirtualGrid, {
props: {
items,
gridStyle: defaultGridStyle,
@@ -262,15 +259,13 @@ describe('VirtualGrid', () => {
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
const renderedItems = screen.getAllByText(/^Item \d+$/)
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -7,10 +8,23 @@ import type {
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function createWrapper(items: WorkflowMenuItem[]) {
return shallowMount(WorkflowActionsList, {
props: { items },
global: { renderStubDefaultSlot: true }
const MenuItemStub = {
template:
'<div data-testid="menu-item" @click="$emit(\'select\')"><slot /></div>',
emits: ['select']
}
const SeparatorStub = {
template: '<hr data-testid="menu-separator" />'
}
function renderList(items: WorkflowMenuItem[]) {
return render(WorkflowActionsList, {
props: {
items,
itemComponent: MenuItemStub,
separatorComponent: SeparatorStub
}
})
}
@@ -20,10 +34,9 @@ describe('WorkflowActionsList', () => {
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).toContain('Save')
expect(wrapper.find('.pi-save').exists()).toBe(true)
expect(screen.getByText('Save')).toBeInTheDocument()
})
it('renders separator items', () => {
@@ -33,24 +46,23 @@ describe('WorkflowActionsList', () => {
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
const html = wrapper.html()
renderList(items)
expect(html).toContain('dropdown-menu-separator-stub')
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
screen.getByTestId('menu-separator')
screen.getByText('Before')
screen.getByText('After')
})
it('dispatches command on select', async () => {
const user = userEvent.setup()
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
await item.vm.$emit('select')
renderList(items)
await user.click(screen.getByTestId('menu-item'))
expect(command).toHaveBeenCalledOnce()
})
@@ -65,9 +77,9 @@ describe('WorkflowActionsList', () => {
}
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).toContain('NEW')
screen.getByText('NEW')
})
it('does not render items with visible set to false', () => {
@@ -82,10 +94,10 @@ describe('WorkflowActionsList', () => {
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
expect(screen.queryByText('Hidden Item')).toBeNull()
screen.getByText('Shown Item')
})
it('does not render badge when absent', () => {
@@ -93,8 +105,8 @@ describe('WorkflowActionsList', () => {
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)
renderList(items)
expect(wrapper.text()).not.toContain('NEW')
expect(screen.queryByText('NEW')).toBeNull()
})
})

View File

@@ -1,86 +1,91 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'
function mountEditor(points: CurvePoint[], extraProps = {}) {
return mount(CurveEditor, {
function renderEditor(points: CurvePoint[], extraProps = {}) {
const { container } = render(CurveEditor, {
props: { modelValue: points, ...extraProps }
})
return { container }
}
function getCurvePath(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-testid="curve-path"]')
function getCurvePath() {
return screen.getByTestId('curve-path')
}
describe('CurveEditor', () => {
it('renders SVG with curve path', () => {
const wrapper = mountEditor([
const { container } = renderEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('svg').exists()).toBe(true)
const curvePath = getCurvePath(wrapper)
expect(curvePath.exists()).toBe(true)
expect(curvePath.attributes('d')).toBeTruthy()
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelector('svg')).toBeInTheDocument()
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
const curvePath = getCurvePath()
expect(curvePath).toBeInTheDocument()
expect(curvePath.getAttribute('d')).toBeTruthy()
})
it('renders a circle for each control point', () => {
const wrapper = mountEditor([
const { container } = renderEditor([
[0, 0],
[0.5, 0.7],
[1, 1]
])
expect(wrapper.findAll('circle')).toHaveLength(3)
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
expect(container.querySelectorAll('circle')).toHaveLength(3)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
it('renders histogram path when provided', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const wrapper = mountEditor(
renderEditor(
[
[0, 0],
[1, 1]
],
{ histogram }
)
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
expect(histogramPath.exists()).toBe(true)
expect(histogramPath.attributes('d')).toContain('M0,1')
const histogramPath = screen.getByTestId('histogram-path')
expect(histogramPath).toBeInTheDocument()
expect(histogramPath.getAttribute('d')).toContain('M0,1')
})
it('does not render histogram path when not provided', () => {
const wrapper = mountEditor([
renderEditor([
[0, 0],
[1, 1]
])
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
expect(screen.queryByTestId('histogram-path')).not.toBeInTheDocument()
})
it('returns empty path with fewer than 2 points', () => {
const wrapper = mountEditor([[0.5, 0.5]])
expect(getCurvePath(wrapper).attributes('d')).toBe('')
renderEditor([[0.5, 0.5]])
expect(getCurvePath().getAttribute('d')).toBe('')
})
it('generates path starting with M and containing L segments', () => {
const wrapper = mountEditor([
renderEditor([
[0, 0],
[0.5, 0.8],
[1, 1]
])
const d = getCurvePath(wrapper).attributes('d')!
const d = getCurvePath().getAttribute('d')!
expect(d).toMatch(/^M/)
expect(d).toContain('L')
})
it('curve path only spans the x-range of control points', () => {
const wrapper = mountEditor([
renderEditor([
[0.2, 0.3],
[0.8, 0.9]
])
const d = getCurvePath(wrapper).attributes('d')!
const d = getCurvePath().getAttribute('d')!
const xValues = d
.split(/[ML]/)
.filter(Boolean)
@@ -95,19 +100,22 @@ describe('CurveEditor', () => {
[0.5, 0.5],
[1, 1]
]
const wrapper = mountEditor(points)
expect(wrapper.findAll('circle')).toHaveLength(3)
const { container } = renderEditor(points)
await wrapper.findAll('circle')[1].trigger('pointerdown', {
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
expect(container.querySelectorAll('circle')).toHaveLength(3)
await fireEvent.pointerDown(container.querySelectorAll('circle')[1], {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
expect(container.querySelectorAll('circle')).toHaveLength(2)
await wrapper.findAll('circle')[0].trigger('pointerdown', {
await fireEvent.pointerDown(container.querySelectorAll('circle')[0], {
button: 2,
pointerId: 1
})
expect(wrapper.findAll('circle')).toHaveLength(2)
expect(container.querySelectorAll('circle')).toHaveLength(2)
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
})
})

View File

@@ -175,7 +175,6 @@
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="tall"
@@ -423,8 +422,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
@@ -444,29 +441,6 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
@@ -586,7 +560,7 @@ const {
totalCount,
resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates, selectedNavItem)
} = useTemplateFiltering(navigationFilteredTemplates)
/**
* Coordinates state between the selected navigation item and the sort order to
@@ -852,14 +826,6 @@ const { isLoading } = useAsyncState(
}
)
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
return (template.includeOnDistributions?.length ?? 0) > 0
? distributions.value.some((d) =>
template.includeOnDistributions?.includes(d)
)
: true
}
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})

View File

@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
@@ -10,10 +11,16 @@ const i18n = createI18n({
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
function renderOption(
props?: Partial<{
credits: number
description: string
selected: boolean
onSelect: () => void
}>
) {
const user = userEvent.setup()
const result = render(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
@@ -24,25 +31,30 @@ const mountOption = (
plugins: [i18n]
}
})
return { user, ...result }
}
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
renderOption({ credits: 5000, description: '~500 videos*' })
expect(screen.getByText('5,000')).toBeInTheDocument()
expect(screen.getByText('~500 videos*')).toBeInTheDocument()
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
const { container } = renderOption({ selected: false })
// eslint-disable-next-line testing-library/no-node-access
const rootDiv = container.firstElementChild as HTMLElement
expect(rootDiv).toHaveClass(
'bg-component-node-disabled',
'border-transparent'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
const selectSpy = vi.fn()
const { user } = renderOption({ onSelect: selectSpy })
await user.click(screen.getByText('1,000'))
expect(selectSpy).toHaveBeenCalledOnce()
})
})

View File

@@ -1,12 +1,15 @@
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { defineComponent, h } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SettingItem from '@/platform/settings/components/SettingItem.vue'
import type { SettingParams } from '@/platform/settings/types'
const i18n = createI18n({
legacy: false,
@@ -17,60 +20,72 @@ vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: vi.fn()
}))
const FormItemStub = defineComponent({
name: 'FormItem',
props: {
item: { type: Object, default: () => ({}) },
id: { type: String, default: undefined },
formValue: { type: null, default: undefined }
},
setup(props) {
return () =>
h('div', { 'data-testid': 'form-item-data' }, JSON.stringify(props.item))
}
})
describe('SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
function renderComponent(setting: SettingParams) {
return render(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
components: {
Tag
},
directives: {
tooltip: Tooltip
},
components: { Tag },
stubs: {
FormItem: FormItemStub,
'i-material-symbols:experiment-outline': true
}
},
directives: { tooltip: Tooltip }
},
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
props: { setting }
})
}
function getFormItemData(container: Element) {
// eslint-disable-next-line testing-library/no-node-access
const el = container.querySelector('[data-testid="form-item-data"]')
return JSON.parse(el!.textContent!)
}
it('translates options that use legacy type', () => {
const wrapper = mountComponent({
setting: {
const { container } = renderComponent(
fromAny({
id: 'Comfy.NodeInputConversionSubmenus',
name: 'Node Input Conversion Submenus',
type: 'combo',
value: 'Top',
defaultValue: 'Top',
options: () => ['Correctly Translated']
}
})
})
)
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
expect(options).toEqual([
const data = getFormItemData(container)
expect(data.options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
const { container } = renderComponent(
fromAny({
id: 'Comfy.NodeInputConversionSubmenus',
name: 'Test Setting',
type: 'boolean',
defaultValue: false,
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
}
})
})
)
// Should not throw an error and tooltip should be preserved as-is
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
const data = getFormItemData(container)
expect(data.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -1,40 +1,17 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Button from '@/components/ui/button/Button.vue'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { defineComponent, onMounted, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: Partial<AuditLog>[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
dataTableFirst: number
tooltipContentMap: Map<string, string>
loadEvents: () => Promise<void>
refresh: () => Promise<void>
onPageChange: (event: { page: number }) => void
}
// Mock the customerEventsService
const mockCustomerEventsService = vi.hoisted(() => ({
getMyEvents: vi.fn(),
formatEventType: vi.fn(),
@@ -43,7 +20,7 @@ const mockCustomerEventsService = vi.hoisted(() => ({
formatDate: vi.fn(),
hasAdditionalInfo: vi.fn(),
getTooltipContent: vi.fn(),
error: { value: null },
error: { value: null as string | null },
isLoading: { value: false }
}))
@@ -57,7 +34,10 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
// Create i18n instance
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -76,78 +56,115 @@ const i18n = createI18n({
}
})
describe('UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
],
total: 2,
const globalConfig = {
plugins: [PrimeVue, i18n, createTestingPinia()],
directives: { tooltip: Tooltip }
}
/**
* The component starts with loading=true and only loads data when refresh()
* is called via template ref. This wrapper auto-calls refresh on mount.
*/
const AutoRefreshWrapper = defineComponent({
components: { UsageLogsTable },
setup() {
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
onMounted(async () => {
await tableRef.value?.refresh()
})
return { tableRef }
},
template: '<UsageLogsTable ref="tableRef" />'
})
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
) {
return {
events,
total: events.length,
page: 1,
limit: 7,
totalPages: 1
totalPages: 1,
...overrides
}
}
describe('UsageLogsTable', () => {
const mockEventsResponse = makeEventsResponse([
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000
},
createdAt: '2024-01-02T10:00:00Z'
}
])
beforeEach(() => {
vi.clearAllMocks()
// Setup default service mock implementations
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'Credits Added'
case EventType.ACCOUNT_CREATED:
return 'Account Created'
case EventType.API_USAGE_COMPLETED:
return 'API Usage'
default:
return type
}
}
})
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
)
mockCustomerEventsService.getEventSeverity.mockImplementation(
(type: string) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
case EventType.ACCOUNT_CREATED:
return 'info'
case EventType.API_USAGE_COMPLETED:
return 'warning'
default:
return 'info'
}
}
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
})
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
return new Date(dateString).toLocaleDateString()
})
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
const { amount, api_name, model, ...otherParams } = event.params || {}
return Object.keys(otherParams).length > 0
})
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
return '<strong>Transaction Id:</strong> txn-123'
})
)
mockCustomerEventsService.formatAmount.mockImplementation(
(amount: number) => {
if (!amount) return '0.00'
return (amount / 100).toFixed(2)
}
)
mockCustomerEventsService.formatDate.mockImplementation(
(dateString: string) => new Date(dateString).toLocaleDateString()
)
mockCustomerEventsService.hasAdditionalInfo.mockImplementation(
(event: AuditLog) => {
const { amount, api_name, model, ...otherParams } =
(event.params as Record<string, unknown>) ?? {}
return Object.keys(otherParams).length > 0
}
)
mockCustomerEventsService.getTooltipContent.mockImplementation(
() => '<strong>Transaction Id:</strong> txn-123'
)
mockCustomerEventsService.error.value = null
mockCustomerEventsService.isLoading.value = false
})
@@ -156,200 +173,146 @@ describe('UsageLogsTable', () => {
vi.restoreAllMocks()
})
const mountComponent = (options = {}) => {
return mount(UsageLogsTable, {
global: {
plugins: [PrimeVue, i18n, createTestingPinia()],
components: {
DataTable,
Column,
Badge,
Button,
Message,
ProgressSpinner
},
directives: {
tooltip: Tooltip
}
},
...options
function renderComponent() {
return render(UsageLogsTable, { global: globalConfig })
}
function renderWithAutoRefresh() {
return render(AutoRefreshWrapper, { global: globalConfig })
}
async function renderLoaded() {
const result = renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument()
})
return result
}
describe('loading states', () => {
it('shows loading spinner when loading is true', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = true
await nextTick()
it('shows loading spinner before refresh is called', () => {
renderComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('shows error message when error exists', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.error = 'Failed to load events'
vm.loading = false
await nextTick()
it('shows error message when service returns null', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = 'Failed to load events'
const messageComponent = wrapper.findComponent(Message)
expect(messageComponent.exists()).toBe(true)
expect(messageComponent.props('severity')).toBe('error')
expect(messageComponent.text()).toContain('Failed to load events')
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
})
})
it('shows data table when loaded successfully', async () => {
const wrapper = mountComponent()
it('shows error message when service throws', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
const vm = wrapper.vm as ComponentInstance
// Wait for component to mount and load data
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
renderWithAutoRefresh()
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Message).exists()).toBe(false)
it('shows data table after loading completes', async () => {
await renderLoaded()
expect(
screen.queryByText('Failed to load events')
).not.toBeInTheDocument()
})
})
describe('data rendering', () => {
it('renders events data correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('renders event type badges', async () => {
await renderLoaded()
const dataTable = wrapper.findComponent(DataTable)
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
expect(dataTable.props('rows')).toBe(7)
expect(dataTable.props('paginator')).toBe(true)
expect(dataTable.props('lazy')).toBe(true)
})
it('renders badge for event types correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const badges = wrapper.findAllComponents(Badge)
expect(badges.length).toBeGreaterThan(0)
// Check if formatEventType and getEventSeverity are called
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
})
it('renders different event details based on event type', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('renders credit added details with formatted amount', async () => {
await renderLoaded()
// Check if formatAmount is called for credit_added events
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders tooltip buttons for events with additional info', async () => {
it('renders API usage details with api name and model', async () => {
await renderLoaded()
expect(screen.getByText('Image Generation')).toBeInTheDocument()
expect(screen.getByText(/sdxl-base/)).toBeInTheDocument()
})
it('renders account created details', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-3',
event_type: 'account_created',
params: {},
createdAt: '2024-01-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Account initialized')).toBeInTheDocument()
})
})
it('renders formatted dates', async () => {
await renderLoaded()
expect(mockCustomerEventsService.formatDate).toHaveBeenCalled()
})
it('renders info buttons for events with additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
await renderLoaded()
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
const infoButtons = screen.getAllByRole('button', {
name: 'Additional Info'
})
expect(infoButtons.length).toBeGreaterThan(0)
})
it('does not render info buttons when no additional info', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
await renderLoaded()
expect(
screen.queryByRole('button', { name: 'Additional Info' })
).not.toBeInTheDocument()
})
})
describe('pagination', () => {
it('handles page change correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
it('calls getMyEvents with initial page params', async () => {
await renderLoaded()
// Simulate page change
const dataTable = wrapper.findComponent(DataTable)
await dataTable.vm.$emit('page', { page: 1 })
expect(vm.pagination.page).toBe(1) // page + 1
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 2,
page: 1,
limit: 7
})
})
it('calculates dataTableFirst correctly', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
await nextTick()
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
})
})
describe('tooltip functionality', () => {
it('generates tooltip content map correctly', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
mockCustomerEventsService.getTooltipContent.mockReturnValue(
'<strong>Test:</strong> value'
)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
})
it('excludes events without additional info from tooltip map', async () => {
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.loading = false
vm.events = mockEventsResponse.events
await nextTick()
const tooltipMap = vm.tooltipContentMap
expect(tooltipMap.size).toBe(0)
})
})
describe('component methods', () => {
it('exposes refresh method', () => {
const wrapper = mountComponent()
it('calls getMyEvents on refresh with page 1', async () => {
await renderLoaded()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('resets to first page on refresh', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
vm.pagination.page = 3
await vm.refresh()
expect(vm.pagination.page).toBe(1)
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
page: 1,
limit: 7
@@ -357,44 +320,41 @@ describe('UsageLogsTable', () => {
})
})
describe('component lifecycle', () => {
it('initializes with correct default values', () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
expect(vm.events).toEqual([])
expect(vm.loading).toBe(true)
expect(vm.error).toBeNull()
expect(vm.pagination).toEqual({
page: 1,
limit: 7,
total: 0,
totalPages: 0
})
})
})
describe('EventType integration', () => {
it('uses EventType enum in template conditions', async () => {
const wrapper = mountComponent()
const vm = wrapper.vm as ComponentInstance
it('renders credit_added event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
vm.loading = false
vm.events = [
{
event_id: 'event-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
]
await nextTick()
await renderLoaded()
// Verify that the component can access EventType enum
expect(EventType.CREDIT_ADDED).toBe('credit_added')
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
})
it('renders api_usage_completed event with correct detail template', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'event-2',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'Test API', model: 'test-model' },
createdAt: '2024-01-02T10:00:00Z'
}
])
)
await renderLoaded()
expect(screen.getByText('Test API')).toBeInTheDocument()
expect(screen.getByText(/test-model/)).toBeInTheDocument()
})
})
})

View File

@@ -1,14 +1,12 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
@@ -16,11 +14,13 @@ import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
const mockLoadingRef = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
loading: mockLoading()
get loading() {
return mockLoadingRef.value
}
}))
}))
@@ -58,62 +58,57 @@ const i18n = createI18n({
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoading.mockReset()
mockLoadingRef.value = false
})
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
return mount(ApiKeyForm, {
function renderComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const result = render(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
plugins: [PrimeVue, i18n],
components: { Button, Form, InputText, Message }
},
props
})
return { ...result, user }
}
it('renders correctly with all required elements', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
expect(screen.getByRole('heading', { name: 'API Key' })).toBeInTheDocument()
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('emits back event when back button is clicked', async () => {
const wrapper = mountComponent()
const onBack = vi.fn()
const { user } = renderComponent({ onBack })
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(onBack).toHaveBeenCalled()
})
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
it('shows loading state when submitting', () => {
mockLoadingRef.value = true
const { container } = renderComponent()
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const buttons = wrapper.findAllComponents(Button)
const submitButton = buttons.find(
(btn) => btn.attributes('type') === 'submit'
)
expect(submitButton?.props('loading')).toBe(true)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const submitButton = container.querySelector('button[type="submit"]')
expect(submitButton).toBeDisabled()
})
it('displays help text and links correctly', () => {
const wrapper = mountComponent()
renderComponent()
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
expect(
screen.getByText('Need an API key?', { exact: false })
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Get one here' })).toHaveAttribute(
'href',
`${getComfyPlatformBaseUrl()}/login`
)
})

View File

@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GradientSlider from './GradientSlider.vue'
import { render, screen } from '@testing-library/vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import GradientSlider from './GradientSlider.vue'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
@@ -10,40 +12,44 @@ const TEST_STOPS: ColorStop[] = [
{ offset: 1, color: [255, 255, 255] }
]
function mountSlider(props: {
function renderSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return mount(GradientSlider, {
return render(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
it('passes min and max to SliderRoot', () => {
renderSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
const thumb = screen.getByRole('slider', { hidden: true })
expect(thumb).toBeInTheDocument()
expect(thumb).toHaveAttribute('aria-valuemin', '-100')
expect(thumb).toHaveAttribute('aria-valuemax', '100')
})
it('renders slider root with track and thumb', () => {
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
const { container } = renderSlider({ modelValue: 0 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('[data-slider-impl]')).toBeInTheDocument()
expect(screen.getByRole('slider', { hidden: true })).toBeInTheDocument()
})
it('does not render SliderRange', () => {
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
const { container } = renderSlider({ modelValue: 50 })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const range = container.querySelector('[data-slot="slider-range"]')
expect(range).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
@@ -44,8 +45,9 @@ const i18n = createI18n({
const mockPopoverHide = vi.fn()
function createWrapper() {
return mount(CanvasModeSelector, {
function renderComponent() {
const user = userEvent.setup()
render(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
@@ -59,94 +61,98 @@ function createWrapper() {
}
}
})
return { user }
}
describe('CanvasModeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render menu with menuitemradio roles and aria-checked', () => {
const wrapper = createWrapper()
renderComponent()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
expect(screen.getByRole('menu')).toBeInTheDocument()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const menuItems = screen.getAllByRole('menuitemradio')
expect(menuItems).toHaveLength(2)
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
expect(menuItems[0]).toHaveAttribute('aria-checked', 'true')
expect(menuItems[1]).toHaveAttribute('aria-checked', 'false')
})
it('should render menu items as buttons with aria-labels', () => {
const wrapper = createWrapper()
renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
expect(item.tagName).toBe('BUTTON')
expect(item).toHaveAttribute('type', 'button')
})
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
expect(menuItems[0]).toHaveAttribute('aria-label', 'Select')
expect(menuItems[1]).toHaveAttribute('aria-label', 'Hand')
})
it('should use roving tabindex based on active mode', () => {
const wrapper = createWrapper()
renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
const menuItems = screen.getAllByRole('menuitemradio')
expect(menuItems[0]).toHaveAttribute('tabindex', '0')
expect(menuItems[1]).toHaveAttribute('tabindex', '-1')
})
it('should mark icons as aria-hidden', () => {
const wrapper = createWrapper()
renderComponent()
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
const menuItems = screen.getAllByRole('menuitemradio')
menuItems.forEach((item) => {
// eslint-disable-next-line testing-library/no-node-access
const icons = item.querySelectorAll('i')
icons.forEach((icon) => {
expect(icon).toHaveAttribute('aria-hidden', 'true')
})
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
const wrapper = createWrapper()
renderComponent()
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[1], 'focus')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
menuItems[0].focus()
await user.keyboard('{ArrowDown}')
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const focusSpy = vi.spyOn(menuItems[0], 'focus')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
menuItems[1].focus()
await user.keyboard('{ArrowUp}')
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const wrapper = createWrapper()
const { user } = renderComponent()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
const menuItems = screen.getAllByRole('menuitemradio')
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
const focusSpy = vi.spyOn(trigger, 'focus')
await menuItems[0].trigger('keydown', { key: 'Escape' })
menuItems[0].focus()
await user.keyboard('{Escape}')
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -89,7 +89,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -134,7 +134,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
@@ -160,7 +160,7 @@ describe('DomWidgets transition grace characterization', () => {
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
mount(DomWidgets, {
render(DomWidgets, {
global: {
stubs: {
DomWidget: true

View File

@@ -260,8 +260,26 @@ function handleColorSelect(subOption: SubMenuOption) {
hide()
}
function constrainMenuHeight() {
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const rootList = menuInstance?.container?.querySelector(
':scope > ul'
) as HTMLElement | null
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {
isOpen.value = true
requestAnimationFrame(constrainMenuHeight)
}
function onMenuHide() {

View File

@@ -1,19 +1,37 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
const mockGetCommand = vi.fn().mockImplementation((commandId: string) => ({
keybinding: {
combo: {
getKeySequences: () => ['Ctrl', '+']
getKeySequences: () => [
'Ctrl',
commandId === 'Comfy.Canvas.ZoomIn'
? '+'
: commandId === 'Comfy.Canvas.ZoomOut'
? '-'
: '0'
]
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
}))
const mockFormatKeySequence = vi
.fn()
.mockImplementation(
(command: {
keybinding: { combo: { getKeySequences: () => string[] } }
}) => {
const seq = command.keybinding.combo.getKeySequences()
if (seq.includes('+')) return 'Ctrl+'
if (seq.includes('-')) return 'Ctrl-'
return 'Ctrl+0'
}
)
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
@@ -23,11 +41,11 @@ const i18n = createI18n({
messages: { en: {} }
})
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
containerStyles: {
value: { backgroundColor: '#fff', borderRadius: '8px' }
}
})
}))
@@ -52,8 +70,8 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
const createWrapper = (props = {}) => {
return mount(ZoomControlsModal, {
function renderComponent(props = {}) {
return render(ZoomControlsModal, {
props: {
visible: true,
...props
@@ -70,90 +88,89 @@ const createWrapper = (props = {}) => {
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
})
it('should execute zoom in command when zoom in button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
expect(zoomInButton).toBeDefined()
await zoomInButton!.trigger('mousedown')
const zoomInButton = screen.getByTestId('zoom-in-action')
await user.click(zoomInButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
})
it('should execute zoom out command when zoom out button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
expect(zoomOutButton).toBeDefined()
await zoomOutButton!.trigger('mousedown')
const zoomOutButton = screen.getByTestId('zoom-out-action')
await user.click(zoomOutButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
})
it('should execute fit view command when fit view button is clicked', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
const fitViewButton = screen.getByTestId('zoom-to-fit-action')
await user.click(fitViewButton)
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const wrapper = createWrapper()
const user = userEvent.setup()
renderComponent()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Emit the input event with PrimeVue's InputNumberInputEvent structure
await inputNumber.vm.$emit('input', { value: 150 })
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('150')
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
})
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
const wrapper = createWrapper()
it('should not call setAppZoomFromPercentage when value is below minimum', async () => {
const user = userEvent.setup()
renderComponent()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('0')
// Test out of range values
await inputNumber.vm.$emit('input', { value: 0 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should not apply zoom values exceeding the maximum', async () => {
const user = userEvent.setup()
renderComponent()
const input = screen.getByRole('spinbutton')
await user.tripleClick(input)
await user.keyboard('100')
mockSetAppZoom.mockClear()
await user.keyboard('1')
await inputNumber.vm.$emit('input', { value: 1001 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display keyboard shortcuts for commands', () => {
const wrapper = createWrapper()
renderComponent()
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut
expect(mockFormatKeySequence).toHaveBeenCalled()
expect(screen.getByText('Ctrl+')).toBeInTheDocument()
expect(screen.getByText('Ctrl-')).toBeInTheDocument()
expect(screen.getByText('Ctrl+0')).toBeInTheDocument()
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should not be visible when visible prop is false', () => {
const wrapper = createWrapper({ visible: false })
renderComponent({ visible: false })
expect(wrapper.find('.absolute').exists()).toBe(false)
expect(screen.queryByTestId('zoom-in-action')).toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import type { Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -105,8 +106,10 @@ describe('ColorPickerButton', () => {
workflowStore.activeWorkflow = createMockWorkflow()
})
const createWrapper = () => {
return mount(ColorPickerButton, {
function renderComponent() {
const user = userEvent.setup()
render(ColorPickerButton, {
global: {
plugins: [PrimeVue, i18n],
directives: {
@@ -114,28 +117,30 @@ describe('ColorPickerButton', () => {
}
}
})
return { user }
}
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
renderComponent()
expect(screen.getByTestId('color-picker-button')).toBeInTheDocument()
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
const button = wrapper.find('button')
const { user } = renderComponent()
const button = screen.getByTestId('color-picker-button')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
await button.trigger('click')
const picker = wrapper.findComponent({ name: 'SelectButton' })
expect(picker.exists()).toBe(true)
expect(picker.findAll('button').length).toBeGreaterThan(0)
await user.click(button)
expect(screen.getByTestId('noColor')).toBeInTheDocument()
expect(screen.getByTestId('red')).toBeInTheDocument()
expect(screen.getByTestId('green')).toBeInTheDocument()
expect(screen.getByTestId('blue')).toBeInTheDocument()
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
await user.click(button)
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
})
})

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 { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
@@ -12,7 +13,6 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
// Mock the utils
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn((node) => !!node?.type)
}))
@@ -21,7 +21,6 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
}))
// Mock the composables
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: vi.fn(() => ({
selectedNodes: {
@@ -49,14 +48,12 @@ describe('ExecuteButton', () => {
})
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
createSpy: vi.fn
})
)
// Reset mocks
const partialCanvas: Partial<LGraphCanvas> = {
setDirty: vi.fn()
}
@@ -64,14 +61,12 @@ describe('ExecuteButton', () => {
mockSelectedNodes = []
// Get store instances and mock methods
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
@@ -81,33 +76,33 @@ describe('ExecuteButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(ExecuteButton, {
const renderComponent = () => {
return render(ExecuteButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:play': { template: '<div class="play-icon" />' }
}
directives: { tooltip: Tooltip }
}
})
}
describe('Rendering', () => {
it('should be able to render', () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
renderComponent()
expect(
screen.getByRole('button', { name: 'Execute selected nodes' })
).toBeTruthy()
})
})
describe('Click Handler', () => {
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
const commandStore = useCommandStore()
const wrapper = mountComponent()
const button = wrapper.find('button')
const user = userEvent.setup()
renderComponent()
await button.trigger('click')
await user.click(
screen.getByRole('button', { name: 'Execute selected nodes' })
)
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.QueueSelectedOutputNodes'

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 { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
@@ -18,6 +19,12 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: vi.fn()
})
}))
describe('InfoButton', () => {
const i18n = createI18n({
legacy: false,
@@ -36,8 +43,8 @@ describe('InfoButton', () => {
vi.clearAllMocks()
})
const mountComponent = () => {
return mount(InfoButton, {
const renderComponent = () => {
return render(InfoButton, {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
@@ -47,9 +54,11 @@ describe('InfoButton', () => {
}
it('should open the info panel on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="info-button"]')
await button.trigger('click')
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -1,9 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { nextTick, reactive } from 'vue'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
@@ -100,16 +100,17 @@ describe('DomWidget disabled style', () => {
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
const { container } = render(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
await nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const root = container.querySelector('.dom-widget') as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})

View File

@@ -1,5 +1,5 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
@@ -11,10 +11,11 @@ describe('HoneyToast', () => {
document.body.innerHTML = ''
})
function mountComponent(
function renderComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
) {
const user = userEvent.setup()
const { unmount } = render(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
@@ -33,48 +34,45 @@ describe('HoneyToast', () => {
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
container: document.body.appendChild(document.createElement('div'))
})
return { user, unmount }
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
expect(screen.getByRole('status')).toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
const { unmount } = renderComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
wrapper.unmount()
unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
const { unmount } = renderComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
wrapper.unmount()
unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
@@ -98,23 +96,21 @@ describe('HoneyToast', () => {
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
const user = userEvent.setup()
const { unmount } = render(TestWrapper, {
container: document.body.appendChild(document.createElement('div'))
})
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Expand')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await user.click(screen.getByTestId('toggle-btn'))
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
expect(screen.getByTestId('content')).toHaveTextContent('expanded')
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Collapse')
wrapper.unmount()
unmount()
})
})

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 { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -27,7 +28,7 @@ const options = [
{ name: 'Option C', value: 'c' }
]
function mountInParent(
function renderInParent(
multiSelectProps: Record<string, unknown> = {},
modelValue: { name: string; value: string }[] = []
) {
@@ -49,12 +50,12 @@ function mountInParent(
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
return { unmount, parentEscapeCount }
}
function dispatchEscape(element: Element) {
@@ -73,30 +74,32 @@ function findContentElement(): HTMLElement | null {
describe('MultiSelect', () => {
it('keeps open-state border styling available while the dropdown is open', async () => {
const { wrapper } = mountInParent()
const user = userEvent.setup()
const { unmount } = renderInParent()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
const trigger = screen.getByRole('button')
expect(trigger.classes()).toContain(
expect(trigger).toHaveClass(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await trigger.trigger('click')
await user.click(trigger)
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(trigger).toHaveAttribute('data-state', 'open')
wrapper.unmount()
unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const user = userEvent.setup()
const { unmount, parentEscapeCount } = renderInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
const content = findContentElement()
@@ -107,48 +110,46 @@ describe('MultiSelect', () => {
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const user = userEvent.setup()
const { unmount } = renderInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
const trigger = screen.getByRole('button')
await user.click(trigger)
await nextTick()
expect(trigger.attributes('data-state')).toBe('open')
expect(trigger).toHaveAttribute('data-state', 'open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
expect(trigger).toHaveAttribute('data-state', 'closed')
wrapper.unmount()
unmount()
})
})
describe('selected count badge', () => {
it('shows selected count when items are selected', () => {
const { wrapper } = mountInParent({}, [
const { unmount } = renderInParent({}, [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' }
])
expect(wrapper.text()).toContain('2')
expect(screen.getByText('2')).toBeInTheDocument()
wrapper.unmount()
unmount()
})
it('does not show count badge when no items are selected', () => {
const { wrapper } = mountInParent()
const multiSelect = wrapper.findComponent(MultiSelect)
const spans = multiSelect.findAll('span')
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
const { unmount } = renderInParent()
expect(countBadge).toBeUndefined()
expect(screen.queryByText(/^\d+$/)).not.toBeInTheDocument()
wrapper.unmount()
unmount()
})
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -37,7 +37,7 @@ function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
function mountInParent(modelValue?: string) {
function renderInParent(modelValue?: string) {
const parentEscapeCount = { value: 0 }
const Parent = {
@@ -55,12 +55,12 @@ function mountInParent(modelValue?: string) {
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
const { unmount } = render(Parent, {
container: document.body.appendChild(document.createElement('div')),
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
return { unmount, parentEscapeCount }
}
async function openSelect(triggerEl: HTMLElement) {
@@ -81,10 +81,10 @@ async function openSelect(triggerEl: HTMLElement) {
describe('SingleSelect', () => {
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const { unmount, parentEscapeCount } = renderInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
const content = findContentElement()
expect(content).not.toBeNull()
@@ -94,23 +94,23 @@ describe('SingleSelect', () => {
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const { unmount } = renderInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
expect(trigger.attributes('data-state')).toBe('open')
const trigger = screen.getByRole('combobox')
await openSelect(trigger)
expect(trigger).toHaveAttribute('data-state', 'open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
expect(trigger).toHaveAttribute('data-state', 'closed')
wrapper.unmount()
unmount()
})
})
})

View File

@@ -1,10 +1,11 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import * as markdownRendererUtil from '@/utils/markdownRendererUtil'
@@ -54,13 +55,11 @@ describe('NodePreview', () => {
description: 'Test node description'
}
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
return mount(NodePreview, {
function renderComponent(nodeDef: ComfyNodeDefV2 = mockNodeDef) {
return render(NodePreview, {
global: {
plugins: [PrimeVue, i18n, pinia],
stubs: {
// Stub stores if needed
}
stubs: {}
},
props: {
nodeDef
@@ -69,18 +68,18 @@ describe('NodePreview', () => {
}
it('renders node preview with correct structure', () => {
const wrapper = mountComponent()
renderComponent()
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
expect(wrapper.find('.node_header').exists()).toBe(true)
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
expect(screen.getByTestId('node-header')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
renderComponent()
const nodeHeader = screen.getByTestId('node-header')
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
expect(nodeHeader).toHaveAttribute('title', mockNodeDef.display_name)
})
it('displays truncated long node names with ellipsis', () => {
@@ -90,17 +89,11 @@ describe('NodePreview', () => {
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
}
const wrapper = mountComponent(longNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
renderComponent(longNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
// Verify the title attribute contains the full name
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
// Verify overflow handling classes are applied
expect(nodeHeader.classes()).toContain('text-ellipsis')
// The actual text content should still be the full name (CSS handles truncation)
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
expect(nodeHeader).toHaveAttribute('title', longNameNodeDef.display_name)
expect(nodeHeader).toHaveTextContent(longNameNodeDef.display_name!)
})
it('handles short node names without issues', () => {
@@ -109,18 +102,18 @@ describe('NodePreview', () => {
display_name: 'Short'
}
const wrapper = mountComponent(shortNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
renderComponent(shortNameNodeDef)
const nodeHeader = screen.getByTestId('node-header')
expect(nodeHeader.attributes('title')).toBe('Short')
expect(nodeHeader.text()).toContain('Short')
expect(nodeHeader).toHaveAttribute('title', 'Short')
expect(nodeHeader).toHaveTextContent('Short')
})
it('applies proper spacing to the dot element', () => {
const wrapper = mountComponent()
const headdot = wrapper.find('.headdot')
renderComponent()
const headdot = screen.getByTestId('head-dot')
expect(headdot.classes()).toContain('pr-3')
expect(headdot).toBeInTheDocument()
})
describe('Description Rendering', () => {
@@ -130,11 +123,13 @@ describe('NodePreview', () => {
description: 'This is a plain text description'
}
const wrapper = mountComponent(plainTextNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(plainTextNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('This is a plain text description')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain(
'This is a plain text description'
)
})
it('renders markdown description with formatting', () => {
@@ -143,13 +138,13 @@ describe('NodePreview', () => {
description: '**Bold text** and *italic text* with `code`'
}
const wrapper = mountComponent(markdownNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(markdownNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<strong>Bold text</strong>')
expect(description.html()).toContain('<em>italic text</em>')
expect(description.html()).toContain('<code>code</code>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<strong>Bold text</strong>')
expect(description.innerHTML).toContain('<em>italic text</em>')
expect(description.innerHTML).toContain('<code>code</code>')
})
it('does not render description element when description is empty', () => {
@@ -158,20 +153,16 @@ describe('NodePreview', () => {
description: ''
}
const wrapper = mountComponent(noDescriptionNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(noDescriptionNodeDef)
expect(description.exists()).toBe(false)
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
})
it('does not render description element when description is undefined', () => {
const { description, ...nodeDefWithoutDescription } = mockNodeDef
const wrapper = mountComponent(
nodeDefWithoutDescription as ComfyNodeDefV2
)
const descriptionElement = wrapper.find('._sb_description')
renderComponent(nodeDefWithoutDescription as ComfyNodeDefV2)
expect(descriptionElement.exists()).toBe(false)
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
})
it('calls renderMarkdownToHtml utility function', () => {
@@ -183,7 +174,7 @@ describe('NodePreview', () => {
description: testDescription
}
mountComponent(nodeDefWithDescription)
renderComponent(nodeDefWithDescription)
expect(spy).toHaveBeenCalledWith(testDescription)
spy.mockRestore()
@@ -196,21 +187,13 @@ describe('NodePreview', () => {
'Safe **markdown** content <script>alert("xss")</script> with `code` blocks'
}
const wrapper = mountComponent(unsafeNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(unsafeNodeDef)
const description = screen.getByTestId('node-description')
// The description should still exist because there's safe content
if (description.exists()) {
// Should not contain script tags (sanitized by DOMPurify)
expect(description.html()).not.toContain('<script>')
expect(description.html()).not.toContain('alert("xss")')
// Should contain the safe markdown content rendered as HTML
expect(description.html()).toContain('<strong>markdown</strong>')
expect(description.html()).toContain('<code>code</code>')
} else {
// If DOMPurify removes everything, that's also acceptable for security
expect(description.exists()).toBe(false)
}
expect(description.innerHTML).not.toContain('<script>')
expect(description.innerHTML).not.toContain('alert("xss")')
expect(description.innerHTML).toContain('<strong>markdown</strong>')
expect(description.innerHTML).toContain('<code>code</code>')
})
it('handles markdown with line breaks', () => {
@@ -219,12 +202,11 @@ describe('NodePreview', () => {
description: 'Line 1\n\nLine 3 after empty line'
}
const wrapper = mountComponent(multilineNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(multilineNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
// Should contain paragraph tags for proper line break handling
expect(description.html()).toContain('<p>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<p>')
})
it('handles markdown lists', () => {
@@ -233,19 +215,19 @@ describe('NodePreview', () => {
description: '- Item 1\n- Item 2\n- Item 3'
}
const wrapper = mountComponent(listNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(listNodeDef)
const description = screen.getByTestId('node-description')
expect(description.exists()).toBe(true)
expect(description.html()).toContain('<ul>')
expect(description.html()).toContain('<li>')
expect(description).toBeInTheDocument()
expect(description.innerHTML).toContain('<ul>')
expect(description.innerHTML).toContain('<li>')
})
it('applies correct styling classes to description', () => {
const wrapper = mountComponent()
const description = wrapper.find('._sb_description')
it('renders description element', () => {
renderComponent()
const description = screen.getByTestId('node-description')
expect(description.classes()).toContain('_sb_description')
expect(description).toBeInTheDocument()
})
it('uses v-html directive for rendered content', () => {
@@ -254,12 +236,11 @@ describe('NodePreview', () => {
description: 'Content with **bold** text'
}
const wrapper = mountComponent(htmlNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(htmlNodeDef)
const description = screen.getByTestId('node-description')
// The component should render the HTML, not escape it
expect(description.html()).toContain('<strong>bold</strong>')
expect(description.html()).not.toContain('&lt;strong&gt;')
expect(description.innerHTML).toContain('<strong>bold</strong>')
expect(description.innerHTML).not.toContain('&lt;strong&gt;')
})
it('prevents XSS attacks by sanitizing dangerous HTML elements', () => {
@@ -269,17 +250,12 @@ describe('NodePreview', () => {
'Normal text <img src="x" onerror="alert(\'XSS\')" /> and **bold** text'
}
const wrapper = mountComponent(maliciousNodeDef)
const description = wrapper.find('._sb_description')
renderComponent(maliciousNodeDef)
const description = screen.getByTestId('node-description')
if (description.exists()) {
// Should not contain dangerous event handlers
expect(description.html()).not.toContain('onerror')
expect(description.html()).not.toContain('alert(')
// Should still contain safe markdown content
expect(description.html()).toContain('<strong>bold</strong>')
// May or may not contain img tag depending on DOMPurify config
}
expect(description.innerHTML).not.toContain('onerror')
expect(description.innerHTML).not.toContain('alert(')
expect(description.innerHTML).toContain('<strong>bold</strong>')
})
})
})

View File

@@ -7,17 +7,22 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
:node-def="nodeDef"
:position="position"
/>
<div v-else class="_sb_node_preview bg-component-node-background">
<div
v-else
class="_sb_node_preview bg-component-node-background"
data-testid="node-preview"
>
<div class="_sb_table">
<div
class="node_header text-ellipsis"
data-testid="node-header"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot pr-3" />
<div class="_sb_dot headdot pr-3" data-testid="head-dot" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>
@@ -76,6 +81,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div
v-if="renderedDescription"
class="_sb_description"
data-testid="node-description"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
backgroundColor: litegraphColors.WIDGET_BGCOLOR

View File

@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
@@ -52,8 +52,10 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const renderMenu = () =>
render(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -75,12 +77,11 @@ describe('JobHistoryActionsMenu', () => {
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountMenu()
const user = userEvent.setup()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
renderMenu()
await user.click(screen.getByTestId('show-run-progress-bar-action'))
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
@@ -90,17 +91,16 @@ describe('JobHistoryActionsMenu', () => {
})
it('opens docked job history sidebar when enabling from the menu', async () => {
const user = userEvent.setup()
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
renderMenu()
await user.click(screen.getByTestId('docked-job-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
@@ -110,14 +110,20 @@ describe('JobHistoryActionsMenu', () => {
})
it('emits clear history from the menu', async () => {
const wrapper = mountMenu()
const user = userEvent.setup()
const clearHistorySpy = vi.fn()
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
render(JobHistoryActionsMenu, {
props: { onClearHistory: clearHistorySpy },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
await user.click(screen.getByTestId('clear-history-action'))
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
expect(clearHistorySpy).toHaveBeenCalledOnce()
})
})

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