Compare commits

..

16 Commits

Author SHA1 Message Date
jaeone94
cea30e2b69 fix: harden asset hash verification abort handling 2026-05-07 21:01:45 +09:00
jaeone94
dfcca34880 refactor: extract asset hash verification 2026-05-07 21:01:45 +09:00
Terry Jia
ea277dec4d FE-446: test(load3d): cover Load3D/Preview3D extensions and config persistence (#11969)
## Summary
Add unit tests for src/extensions/core/load3d.ts (Load3D and Preview3D
nodeCreated/onExecuted, beforeRegisterNodeDef, getNodeMenuItems, camera
matrix generation guard) and extend Load3DConfiguration tests for the
Scene/Camera/Light config persistence + settingStore fallback paths.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11969-FE-446-test-load3d-cover-Load3D-Preview3D-extensions-and-config-persistence-3576d73d365081c2a847e0dc9621a75d)
by [Unito](https://www.unito.io)
2026-05-07 07:56:07 -04:00
Terry Jia
a7aa124c10 FE-407: fix(assets): show 3D thumbnail without reopening the panel (#11972)
## Summary
After persistThumbnail uploads the preview, patch the in-memory asset by
name (the cross-API stable id) so an open Asset panel reflects the new
preview_url. Media3DTop also picks up the patched preview_url directly,
bypassing the IntersectionObserver gate.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/ba0b753f-fede-43c0-b790-5c19a82455f9


after


https://github.com/user-attachments/assets/6273ec0b-0d2e-4355-9889-68c3550f1a72

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11972-FE-407-fix-assets-show-3D-thumbnail-without-reopening-the-panel-3576d73d365081febcbfe6ae84a4a68b)
by [Unito](https://www.unito.io)
2026-05-07 07:55:30 -04:00
Terry Jia
9c62bbc74a FE-412: fix(load3d): persist SaveGLB last model so it survives tab switch (#11965)
## Summary
Mirror the Preview3D pattern: store the executed file path and folder on
node.properties and reload them in nodeCreated so the mesh re-appears
when the node is recreated (e.g. after switching workflow tabs).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11965-FE-412-fix-load3d-persist-SaveGLB-last-model-so-it-survives-tab-switch-3576d73d365081a98bcadbd9a81539d0)
by [Unito](https://www.unito.io)
2026-05-07 07:54:45 -04:00
Benjamin Lu
f0e16cdf46 ci: handle skipped e2e workflow consumers (#11575)
## Summary

Follow-up to #11568 and #11785. Keeps the E2E coverage workflow clean
when `CI: Tests E2E` is intentionally skipped and no coverage shard
artifacts are produced.

## Changes

- Detect whether downloaded E2E coverage shard artifacts contain any
`coverage.lcov` files.
- Treat missing coverage shards as an intentionally skipped coverage run
instead of running lcov, Codecov, or Pages deployment on missing files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11575-ci-handle-skipped-e2e-workflow-consumers-34b6d73d3650813f92e1d7d6af0bf958)
by [Unito](https://www.unito.io)
2026-05-07 04:20:36 -07:00
jaeone94
0658c1ac9c refactor: align asset pagination schema (#11899)
## Summary

Align the asset list pagination schema with generated ingest-types
metadata and remove the now-unneeded missing `has_more` fallback branch.

## Changes

- **What**: Reuse `zListAssetsResponse` for `total` and `has_more`, keep
the local loose `AssetItem` shape, and simplify `getAllAssetsByTag()` to
trust the required `has_more` contract.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This is PR 1 of 4 in the missing asset follow-up stack:

1. This PR - Asset schema / pagination cleanup
2. #11900 - Missing asset hash verification utility cleanup
3. #11901 - Browser regression coverage for public input assets
4. #11902 - TanStack Query public-input cache replacement

The key decision is intentionally narrow: pagination metadata now comes
from generated ingest-types, but asset item validation remains locally
loose to avoid changing UI/store synthetic asset shapes in this PR.
`asset_hash` nullability remains unchanged because absent-vs-null hash
semantics are still a backend/API contract follow-up.

Addresses #11894

## Screenshots (if applicable)

N/A
2026-05-07 10:27:15 +00:00
pythongosssss
997501d8fb test: add e2e test for metadata parsing on workflow load (#11522)
## Summary

Adds e2e testing to ensure workflows are correctly loaded from each of
the supported file types

## Changes

- **What**: 
- add png generation
- add mime types for missing files
- add test that loads file and ensures node is present

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11522-test-add-e2e-test-for-metadata-parsing-on-workflow-load-3496d73d36508101ad67d24af1810cec)
by [Unito](https://www.unito.io)
2026-05-07 09:58:52 +00:00
Christian Byrne
ab6e5ba094 feat: boost SaveImageAdvanced node frequency for search ranking (#11853)
*PR Created by the Glary-Bot Agent*

---

Adds an entry for the new `SaveImageAdvanced` node to
`public/assets/sorted-custom-node-map.json` with the same frequency stat
(1762) as the existing `SaveImage` node, so the new Save Image node
ranks at the top of search results when typing "save" — matching the
original node's behavior.

Context: the new Save Image node ([Notion
spec](https://www.notion.so/comfy-org/Save-Image-94a77c506ce145fc9b8c477c52091a04))
replaces/deprecates the original `SaveImage`. Search ranking uses the
static node frequency map; the new node had no entry and was therefore
ranked at frequency 0. Mirroring the original's stat is the manual-boost
approach discussed in the thread.

## Changes
- `public/assets/sorted-custom-node-map.json`: add `"SaveImageAdvanced":
1762` directly after `"SaveImage": 1762` to preserve descending sort
order.

## Verification
- `pnpm typecheck`, `pnpm lint`, and `pnpm format` all pass via
lint-staged on commit.
- JSON validated and entry placement confirmed (position 4, between
`SaveImage` and `VAEDecode`).
- Review (oracle) ran clean: 0 critical / 0 warning / 0 suggestion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11853-feat-boost-SaveImageAdvanced-node-frequency-for-search-ranking-3546d73d36508168b058d9d750fc3c56)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 09:45:25 +00:00
Yourz
2322a5a497 fix: use webm video for VFX use case right asset (#12040)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces `right1.webp` with `right1.webm` in the VFX panel of
`UseCaseSection`. `BlobMedia` already auto-detects `.webm` URLs and
mounts a `<video>` element (with the `.webp` as poster), so this single
URL swap is the only change required — matching the pattern used by the
other 4 use-case panels.

## Files changed

- `apps/website/src/components/home/UseCaseSection.vue` — swap
`right1.webp` → `right1.webm`.

## Verification

- `pnpm exec nx run website:typecheck` — clean
- `pnpm exec eslint` on changed file — clean
- `pnpm exec oxfmt --check` — clean
- pre-commit lint-staged hooks — passed

## Reviewer note (Oracle finding)

VFX is the default active panel, so the homepage's initial right-rail
asset moves from ~131 KB `.webp` to ~4.1 MB `.webm`. Behaviorally
consistent with the other 4 panels (which already use `.webm`), but
worth confirming whether `right1.webm` should be re-encoded smaller on
the CDN before promoting this PR out of draft.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12040-fix-use-webm-video-for-VFX-use-case-right-asset-3596d73d365081829976f37b733840f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-05-07 09:32:53 +00:00
Dante
0bc951fd12 fix: clarify unsaved-changes modal buttons and fix sign-out 3-state (#11669)
## Summary

The dirtyClose modal had three buttons (`Cancel | No | Save`) and the
sign-out flow collapsed two distinct outcomes (deny vs. dismiss) into a
single early return — so today clicking "No" *cancels* sign-out instead
of signing out without saving, and clicking "Save" never actually saves
before logging out. This PR drops `Cancel` for `dirtyClose`, gives each
caller a context-specific deny label, and fixes the sign-out 3-state
handling.

- Fixes
[FE-419](https://linear.app/comfyorg/issue/FE-419/unsaved-changes-modal-uses-confusing-button-labels)

## Changes

- **What**:
- `ConfirmationDialogContent.vue`: hide `Cancel` for
`type='dirtyClose'`; add `denyLabel?: string` prop; autofocus `Save`
(preserves work on Enter).
  - `dialogService.confirm()`: accept and forward `denyLabel`.
- `useAuthActions.logout`: handle `null` (cancel) / `false` (sign out
anyway, no save) / `true` (save each modified workflow, then logout)
distinctly. Pass `denyLabel: 'Sign out anyway'`.
  - `workflowService.closeWorkflow`: pass `denyLabel: 'Close anyway'`.
- i18n: add `auth.signOut.signOutAnyway` and
`sideToolbar.workflowTab.closeAnyway`.
- **Breaking**: none. The `denyLabel` prop is optional and falls back to
`g.no`.

## Review Focus

- The "Save" branch in `useAuthActions.logout` now iterates
`workflowStore.modifiedWorkflows` and awaits
`useWorkflowService().saveWorkflow(workflow)` for each before calling
`authStore.logout()`. The close-tab path
(`workflowService.closeWorkflow`) was already correct — only the
sign-out path needed the same shape.
- `ConfirmationDialogContent` autofocus moves from `Cancel` (gone for
`dirtyClose`) to `Save`. The dialog is still dismissable via ESC /
outside-click, which routes through `dialogComponentProps.onClose →
resolve(null)` — sign-out and close-tab both treat `null` as cancel.
- Out of scope: the native browser `beforeunload` warning
(`UnloadWindowConfirmDialog.vue`) is a separate flow and never reaches
the in-app modal.

## Tests

- Unit (`useAuthActions.test.ts`, new): logout handles `null` / `false`
/ `true` / no-modified-workflows; saves *every* modified workflow before
`authStore.logout`; passes `denyLabel='Sign out anyway'`.
- Unit (`ConfirmationDialogContent.test.ts`): Cancel hidden for
`dirtyClose`; custom `denyLabel` rendered; falls back to `g.no` when
omitted.
- E2E (`workflowTabs.spec.ts`): modified-tab close shows `Close anyway`
(not `No`) and no `Cancel`; clicking `Close anyway` removes the tab; ESC
keeps the tab.

## screenshot

### AS IS 

<img width="816" height="379" alt="Screenshot 2026-04-27 at 5 40 19 PM"
src="https://github.com/user-attachments/assets/a8e39403-bf72-455a-8d86-6ceb1f94ac85"
/>

<img width="923" height="396" alt="Screenshot 2026-04-27 at 5 40 38 PM"
src="https://github.com/user-attachments/assets/08031c7c-b3a6-45d7-a4dc-5dcb4e63cfa0"
/>


### TO BE 

<img width="1661" height="872" alt="Screenshot 2026-04-27 at 5 43 40 PM"
src="https://github.com/user-attachments/assets/b89d160b-be66-450e-981e-32b1591f6841"
/>


<img width="1488" height="584" alt="Screenshot 2026-04-27 at 5 44 21 PM"
src="https://github.com/user-attachments/assets/b3a141a7-1f3b-4f25-85a9-49529229c28b"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11669-fix-clarify-unsaved-changes-modal-buttons-and-fix-sign-out-3-state-34f6d73d365081bf8afad8e146b3b990)
by [Unito](https://www.unito.io)
2026-05-07 02:49:02 -07:00
Christian Byrne
0446ca7a18 fix: route default topbar feedback button to Typeform (#11863)
*PR Created by the Glary-Bot Agent*

---

## Summary

PR #10890 routed the legacy action bar feedback button and the Help
Center feedback item to the nightly Typeform survey, but the **default
topbar feedback button** in `WorkflowTabs.vue` still called
`buildFeedbackUrl()` and opened Zendesk. Since `Comfy.UI.TabBarLayout`
defaults to `Default` (not `Legacy`), most Cloud/Nightly users were
clicking the WorkflowTabs button and never reaching the Typeform survey
— explaining the lack of survey responses.

## Changes

- Added a shared `buildFeedbackTypeformUrl(source)` helper in
`platform/support/config.ts` that tags the survey URL with:
- `distribution`: `ccloud` / `oss-nightly` / `oss` (preserves the
build-tagging the old `buildFeedbackUrl()` sent to Zendesk so responses
stay segmented)
- `source`: `topbar` / `action-bar` / `help-center` (identifies which UI
entry point launched the survey)

Tags are passed via the URL fragment (Typeform's hidden-field
convention), so they reach the survey but are never sent to the server
in the request line.
- `WorkflowTabs.vue`: replaced `buildFeedbackUrl()` with
`buildFeedbackTypeformUrl('topbar')`.
- `cloudFeedbackTopbarButton.ts` and `HelpCenterMenuContent.vue`: use
the shared builder with their respective source labels instead of inline
URL literals.
- Removed the now-unused `buildFeedbackUrl()` and
`ZENDESK_FEEDBACK_FORM_ID` (knip-clean). `buildSupportUrl()` is
preserved — `Comfy.ContactSupport` (the Help Center "Help" item) still
routes to Zendesk as before.
- Added unit tests for the builder, the WorkflowTabs feedback button,
the legacy action bar button, and the Help Center feedback item
(covering both the Cloud/Nightly Typeform path and the OSS
`Comfy.ContactSupport` fallback).

## Verification

- `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm knip`: clean (one
pre-existing unrelated lint warning in `useWorkspaceBilling.test.ts`)
- `pnpm test:unit` (impacted scope): 506/506 passing, including 13 new
tests

## Review Focus

- Cloud/Nightly gating in `WorkflowTabs.vue` (`v-if="isCloud ||
isNightly"`) is unchanged and matches PR #10890's gating philosophy.
- The Help Center "Help" item and `Comfy.ContactSupport` command
intentionally still route to Zendesk — feedback ≠ support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11863-fix-route-default-topbar-feedback-button-to-Typeform-3556d73d3650815fb446dac33095d4be)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 02:45:05 -07:00
Terry Jia
653ee48444 FE-557: fix(painter): responsive label layout + correct resize min-height (#12025)
## Summary

- WidgetPainter: stack label above widget when controls width < 350px,
side-by-side otherwise; labels are always rendered (no longer hidden
when narrow).
- useNodeResize: re-measure the node's intrinsic min content height on
every pointermove instead of capturing it once at drag start, so the
height clamp tracks widgets whose controls reflow taller as width
shrinks (e.g. painter switching to compact layout). Without this, the
node visually sticks at its current height and the user has to release
and grab the corner again to free it.
- Add unit tests for both changes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/74889ad5-63a7-439f-b8e4-0185ed95327f


after


https://github.com/user-attachments/assets/bca77c36-2f90-4685-8603-f8f9c02abe77

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12025-FE-557-fix-painter-responsive-label-layout-correct-resize-min-height-3586d73d365081cf9036f7d52bfabe6c)
by [Unito](https://www.unito.io)
2026-05-07 04:11:51 -04:00
Yourz
81d9df61f2 fix(website): tighten GitHub ticker spacing on wide screens (#12021)
*PR Created by the Glary-Bot Agent*

---

## Summary

The GitHub star ticker reads as detached from the GitHub octocat icon on
wider viewports. The CSS gap is constant (`gap-2` = 8px), but the 28px
icon next to a 20px badge plus the fixed gap make the pair look like two
separate items at `lg+` widths instead of a single coupled unit.

## Change

`apps/website/src/components/common/GitHubStarBadge.vue`:

- Inner gap `gap-2` → `gap-1` (8px → 4px) so the badge and icon sit
closer together.
- Icon `size-7` → `size-6 shrink-0` (28px → 24px) so the icon height is
closer to the badge height (20px) and the pair reads as one unit.

The outer `gap-2` between CTA items in `SiteNav.vue` is intentionally
unchanged — it is the correct spacing between unrelated CTA elements.

## Verification

- `pnpm typecheck` — 0 errors (1 pre-existing hint in unrelated file).
- `pnpm format:check` — clean.
- `pnpm exec eslint
apps/website/src/components/common/GitHubStarBadge.vue` — clean.
- `pnpm test:unit` (apps/website) — 30/30 pass.
- Pre-commit hook (oxfmt + oxlint + eslint + typecheck +
typecheck:website) — passed.
- `/review` (Oracle) — 0 critical, 0 warnings, 0 suggestions.
- Manual visual verification at 1280px, 1920px, and 2560px viewports.
- Tested longer star strings (`999.9K`, `1.2M`) — no wrapping.

## Before / After (2560px viewport)

Before: badge and octocat appear visually detached.
After: badge and octocat read as a single tightly-coupled unit.

## Screenshots

![Before — 2560px nav: 112K badge looks detached from GitHub
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509381-5426120f-a699-4633-8645-62d5b7e26493.png)

![After — 2560px nav: 112K badge and GitHub icon read as one
unit](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054509735-fc6c7bbe-4cd8-4f40-a8c7-9e5b9d0ad2a2.png)

![Before — 1280px nav
baseline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510078-3c5e0b33-b6f4-4b85-b6a6-6ca0c1651e85.png)

![After — 1280px nav: still visually
balanced](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510429-db5233f4-3f16-402e-b608-6f2230ff8ced.png)

![After — 2560px CTA close-up: badge + icon tightly
coupled](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5ac405b162f5db2c1f077b87e73caf4339911cb07834849a01c4b97409d844d3/pr-images/1778054510733-6305f2a6-408c-40b8-b3ea-3ae10cb1d171.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12021-fix-website-tighten-GitHub-ticker-spacing-on-wide-screens-3586d73d365081be8d66dfbb22b8dc2c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-07 06:03:27 +00:00
Rizumu Ayaka
f4358cb161 perf: drop useMouseInElement to fix templates search lag (#12023)
## Summary

Replace `useMouseInElement` in `CompareSliderThumbnail` with a local
`@mousemove` handler to eliminate ~1s of forced layout per keystroke in
the templates dialog search.

## Changes

- **What**: Profiling the templates dialog search (4× CPU throttle,
cloud distribution) showed a single `getClientRects` block at 977ms
inside Vue's `flushPostFlushCbs → update` path on every keystroke. The
call traces back to VueUse's `useMouseInElement`, which (a) shares a
global `mousemove` listener via `useMouse`, and (b) runs
`el.getBoundingClientRect()` inside a `watch([targetRef, x, y], …, {
immediate: true })`. Every template card with `thumbnailVariant ===
'compareSlider'` mounts one instance — when search filters change and
the page's compareSlider cards re-mount, each instance's `immediate`
watch fires a rect read against freshly-inserted DOM, forcing
synchronous layout of the entire new subtree. With many cards on screen
the costs stack into the ~977ms block. Replaced with a native
`@mousemove` listener bound directly to the slider container; the rect
is read from `event.currentTarget` only when the mouse is actually over
that one card, so the work no longer scales with mounted instance count
and is gated by real pointer activity.
- **Breaking**: None
- **Dependencies**: None

## Review Focus

- UX is unchanged: `sliderPosition` still follows the mouse during hover
and keeps its last value on mouseleave (matches previous behaviour where
`if (!isHovered) return` simply stopped updates without resetting).
- The same `useMouseInElement` pattern still exists in
`src/platform/workflow/sharing/composables/useSliderFromMouse.ts` (used
by `ComfyHubThumbnailStep` in the publish dialog). That path is
single-instance and off the templates hot path, so it's left untouched
to keep this PR scoped — happy to fix it in a follow-up.
- New tests use `userEvent.pointer({ coords })` with a stubbed
`getBoundingClientRect` to lock in the native mousemove path and the
zero-width guard.

## E2E coverage

No `browser_tests/` changes. The `fix:` commit is a defensive clamp
inside `updateSliderPosition`; the overshoot it guards against comes
from subpixel rounding and stale rects observed during hover-in, neither
of which can be deterministically reproduced under Playwright. The perf
change itself is verified via the DevTools profiler (forced-layout block
disappears) — also not assertable as a stable e2e signal across CI
hardware. Both the mousemove-driven slider behavior and the clamp guard
are covered by unit tests in
`src/components/templates/thumbnails/CompareSliderThumbnail.test.ts` (8
cases, including out-of-range pointer coordinates and zero-width
containers).
2026-05-07 05:03:33 +00:00
Comfy Org PR Bot
5948002dee 1.45.0 (#12037)
Minor version increment to 1.45.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12037-1-45-0-3596d73d365081609323df8b5aac04ce)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-07 04:28:55 +00:00
190 changed files with 5936 additions and 7281 deletions

View File

@@ -20,6 +20,8 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -37,31 +39,33 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -82,7 +86,7 @@ jobs:
done
- name: Upload merged coverage data
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -91,7 +95,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -100,6 +104,7 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
@@ -114,6 +119,7 @@ jobs:
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -122,7 +128,9 @@ jobs:
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
}
test.describe('Home', { tag: '@visual' }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 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: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
toHaveScreenshot: { maxDiffPixels: 100 }
},
...maybeLocalOptions,
webServer: {

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-2 lg:flex"
class="hidden shrink-0 items-center gap-1 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-7"
class="bg-primary-comfy-yellow block size-6 shrink-0"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
},
{
label: t('useCase.advertising', locale),

View File

@@ -282,12 +282,10 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true,
url
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -319,7 +317,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto({ url })
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -346,8 +344,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
async goto() {
await this.page.goto(this.url)
}
async nextFrame() {

View File

@@ -217,20 +217,13 @@ export class VueNodeHelpers {
}
}
/**
* Locator for the Enter Subgraph footer button.
*/
getSubgraphEnterButton(nodeId?: string): Locator {
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const editButton = this.getSubgraphEnterButton(nodeId)
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -6,71 +6,6 @@ import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
function readFilePayload(filePath: string) {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
return { bufferArray, fileName, fileType }
}
async function dispatchFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const target = document.activeElement ?? document
target.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
}, payload)
}
async function interceptNextFilePaste(
page: Page,
payload: ReturnType<typeof readFilePayload>
): Promise<void> {
await page.evaluate(({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
document.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
)
},
{ capture: true, once: true }
)
}, payload)
}
type PasteFileOptions = {
mode?: 'keyboard' | 'direct'
}
export class ClipboardHelper {
constructor(
private readonly keyboard: KeyboardHelper,
@@ -85,20 +20,43 @@ export class ClipboardHelper {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(
filePath: string,
{ mode = 'keyboard' }: PasteFileOptions = {}
): Promise<void> {
const payload = readFilePayload(filePath)
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
if (mode === 'keyboard') {
await interceptNextFilePaste(this.page, payload)
await this.paste()
return
}
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
// Dispatch the app-level paste event with file clipboardData directly.
await dispatchFilePaste(this.page, payload)
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -1,4 +1,5 @@
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Page } from '@playwright/test'
@@ -13,6 +14,7 @@ export class DragDropHelper {
async dragAndDropExternalResource(
options: {
fileName?: string
filePath?: string
url?: string
dropPosition?: Position
waitForUpload?: boolean
@@ -22,13 +24,14 @@ export class DragDropHelper {
const {
dropPosition = { x: 100, y: 100 },
fileName,
filePath,
url,
waitForUpload = false,
preserveNativePropagation = false
} = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
if (!fileName && !filePath && !url)
throw new Error('Must provide fileName, filePath, or url')
const evaluateParams: {
dropPosition: Position
@@ -39,12 +42,22 @@ export class DragDropHelper {
preserveNativePropagation: boolean
} = { dropPosition, preserveNativePropagation }
if (fileName) {
const filePath = assetPath(fileName)
const buffer = readFileSync(filePath)
if (fileName || filePath) {
const resolvedPath = filePath ?? assetPath(fileName!)
const displayName = fileName ?? basename(resolvedPath)
let buffer: Buffer
try {
buffer = readFileSync(resolvedPath)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
{ cause: error }
)
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.fileName = displayName
evaluateParams.fileType = getMimeType(displayName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}
@@ -148,6 +161,13 @@ export class DragDropHelper {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropFilePath(
filePath: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
): Promise<void> {
return this.dragAndDropExternalResource({ filePath, ...options })
}
async dragAndDropURL(
url: string,
options: {

View File

@@ -1,26 +1,19 @@
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return () => count
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -8,7 +8,6 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -76,15 +75,7 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -1,250 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.mp3')) return 'audio/mpeg'
if (name.endsWith('.flac')) return 'audio/flac'
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'

View File

@@ -1,3 +1,7 @@
export function assetPath(fileName: string): string {
return `./browser_tests/assets/${fileName}`
}
export function metadataFixturePath(fileName: string): string {
return `./src/scripts/metadata/__fixtures__/${fileName}`
}

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -147,68 +147,5 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -1,47 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

View File

@@ -0,0 +1,62 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
type MetadataFixture = {
fileName: string
parser: string
}
// Each fixture embeds the same single-KSampler workflow (see
// scripts/generate-embedded-metadata-test-files.py), exercising a different
// parser in src/scripts/metadata/. Dropping the file should import that
// workflow.
const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.png', parser: 'png' },
{ fileName: 'with_metadata.avif', parser: 'avif' },
{ fileName: 'with_metadata.webp', parser: 'webp' },
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
{ fileName: 'with_metadata.flac', parser: 'flac' },
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_metadata.opus', parser: 'ogg' },
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
})
for (const { fileName, parser } of FIXTURES) {
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the fixture'
).toHaveLength(1)
})
})
}
}
)

View File

@@ -1,65 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test('Controls collapse to single column in compact mode', async ({
test('Controls stack label above widget in compact mode', async ({
comfyPage
}) => {
const painterWidget = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands')
const toolLabel = painterWidget.getByText('Tool', { exact: true })
const brushButton = painterWidget.getByText('Brush', { exact: true })
await expect(
toolLabel,
'tool label should be visible in two-column layout'
'tool label should be visible in wide layout'
).toBeVisible()
const wideLabelBox = await toolLabel.boundingBox()
const wideBrushBox = await brushButton.boundingBox()
expect(
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
'label should sit to the left of the brush button in wide layout'
).toBe(true)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(
toolLabel,
'tool label should hide in compact single-column layout'
).toBeHidden()
'tool label should remain visible in compact layout'
).toBeVisible()
await expect
.poll(
async () => {
const labelBox = await toolLabel.boundingBox()
const brushBox = await brushButton.boundingBox()
if (!labelBox || !brushBox) return false
return labelBox.y + labelBox.height <= brushBox.y
},
{
message: 'label should stack above the brush button in compact layout'
}
)
.toBe(true)
})
test('Multiple sequential strokes at different positions all accumulate', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -54,44 +54,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
.toBe(initialCount - 1)
})
test('info button opens the right-side info tab in new menu mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
test('info button opens properties panel', async ({ comfyPage }) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click()
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(panel).toContainText('KSampler')
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('info button is hidden when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(
comfyPage.selectionToolbox.getByTestId('info-button')
).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -19,19 +18,70 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
await openMoreOptions(comfyPage)
const nodeInfoButton = comfyPage.page.getByRole('menuitem', {
name: 'Node Info'
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
exact: true
})
await expect(nodeInfoButton).toBeHidden()
await expect(nodeInfoButton).toBeVisible()
await nodeInfoButton.click()
await comfyPage.nextFrame()
})
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
@@ -40,14 +90,11 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -1,145 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -174,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const localeRequest = await localeRequestPromise
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

@@ -75,24 +75,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await expect(renamedNode).toBeVisible()
})
test('should open node info in the right side panel via context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', false)
await expect(comfyPage.menu.propertiesPanel.root).toBeHidden()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Node Info')
const panel = comfyPage.menu.propertiesPanel.root
await expect(panel).toBeVisible()
await expect(panel.getByTestId('panel-tab-info')).toHaveAttribute(
'aria-selected',
'true'
)
await expect(comfyPage.menu.nodeLibraryTab.selectedTabButton).toBeHidden()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {

View File

@@ -1,5 +1,3 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -41,19 +39,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
x: box.x + box.width / 2,
y: box.y + box.height * 0.75
}
await comfyPage.canvasOps.dragAndDrop(start, {
x: start.x + 120,
y: start.y + 80
})
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
@@ -105,63 +90,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expectPosChanged(headerPos, afterPos)
})
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
await comfyPage.nodeOps.addNode(
'ModelSamplingFlux',
{},
{
x: 500,
y: 200
}
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
const showButton = node.getByText('Show advanced inputs')
const widgets = node.locator('.lg-node-widget')
await expect(showButton).toBeVisible()
await expect(widgets).toHaveCount(2)
const beforePos = await node.boundingBox()
if (!beforePos) throw new Error('Node has no bounding box')
await dragFromTabButton(comfyPage, showButton)
await expect(showButton).toBeVisible()
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
await expect(widgets).toHaveCount(2)
const afterPos = await node.boundingBox()
if (!afterPos) throw new Error('Node missing after drag')
await expectPosChanged(beforePos, afterPos)
})
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const beforePos = await subgraphNode.getPosition()
await dragFromTabButton(
comfyPage,
comfyPage.vueNodes.getSubgraphEnterButton('2')
)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
const afterPos = await subgraphNode.getPosition()
await expectPosChanged(beforePos, afterPos)
})
test('should move all selected nodes together when dragging one with Meta held', async ({
comfyPage
}) => {

View File

@@ -4,7 +4,6 @@ import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
@@ -14,9 +13,7 @@ import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import type { NodeError } from '@/schemas/apiSchema'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -25,61 +22,6 @@ const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
const LOAD_IMAGE_INPUT_NAME = 'image'
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
function buildLoadImageRequiredInputError(): NodeError {
return {
class_type: 'LoadImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
details: '',
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
}
]
}
}
async function surfaceLoadImageMissingInputError(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[loadImageId]: buildLoadImageRequiredInputError()
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
}
async function selectLoadImageNodeForPaste(
comfyPage: ComfyPage,
loadImageId: string
): Promise<void> {
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(Number(nodeId))
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
window.app!.canvas.selectNode(node)
window.app!.canvas.current_node = node
}, loadImageId)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const loadImageId = String(loadImageNode.id)
return {
loadImageId,
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
}
}
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
@@ -249,74 +191,6 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user drops an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('drop an image onto the Load Image node', async () => {
const dropPosition =
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
if (!dropPosition) {
throw new Error('Load Image node center must be available for drop')
}
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
dropPosition,
waitForUpload: true
})
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user pastes an image file onto Load Image', async ({
comfyPage
}) => {
const { loadImageId, innerWrapper, imageWidget } =
await setupLoadImageErrorScenario(comfyPage)
await test.step('queue with missing image input to surface the error', async () => {
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('paste an image while Load Image is selected', async () => {
await comfyPage.canvas.focus()
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
)
.toBe('LoadImage')
const uploadResponse = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
// File clipboard contents cannot be seeded reliably in Playwright;
// use the direct document paste mode to exercise usePaste.
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
mode: 'direct'
})
await uploadResponse
await expect
.poll(() => imageWidget.getValue())
.toContain(LOAD_IMAGE_UPLOAD_FILE)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {

View File

@@ -26,10 +26,6 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

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

View File

@@ -524,18 +524,9 @@ export type ImportPublishedAssetsRequest = {
*/
published_asset_ids: Array<string>
/**
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
* The share ID of the published workflow these assets belong to. Required for authorization.
*/
share_id?: string | null
share_id: string
}
/**

View File

@@ -310,8 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
share_id: z.string().min(1).max(64)
})
/**

View File

@@ -3,14 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
joinFilePath,
truncateFilename
} from './formatUtil'
@@ -301,42 +299,6 @@ describe('formatUtil', () => {
})
})
describe('joinFilePath', () => {
it('joins subfolder and filename with normalized slash separators', () => {
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
'nested/folder/child/file.png'
)
})
it('trims boundary separators without changing the filename body', () => {
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
'nested/folder/file.png'
)
})
it('returns the normalized filename when no subfolder is provided', () => {
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
})
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
})
})
describe('getFilePathSeparatorVariants', () => {
it('returns slash and backslash variants for nested paths', () => {
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
'nested/folder/file.png',
'nested\\folder\\file.png'
])
})
it('returns a single value when no separator is present', () => {
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

View File

@@ -256,31 +256,6 @@ export function isValidUrl(url: string): boolean {
}
}
export function joinFilePath(
subfolder: string | null | undefined,
filename: string | null | undefined
): string {
const normalizedSubfolder = normalizeFilePathSeparators(
subfolder ?? ''
).replace(/^\/+|\/+$/g, '')
const normalizedFilename = normalizeFilePathSeparators(
filename ?? ''
).replace(/^\/+/g, '')
if (!normalizedSubfolder) return normalizedFilename
if (!normalizedFilename) return normalizedSubfolder
return `${normalizedSubfolder}/${normalizedFilename}`
}
export function getFilePathSeparatorVariants(filepath: string): string[] {
const slashPath = normalizeFilePathSeparators(filepath)
const backslashPath = slashPath.replace(/\//g, '\\')
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
}
function normalizeFilePathSeparators(filepath: string): string {
return filepath.replace(/[\\/]+/g, '/')
}
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -299,7 +274,8 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
const normalizedPath = normalizeFilePathSeparators(filepath)
const normalizedPath = filepath
.replace(/[\\/]+/g, '/') // Normalize path separators
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash

View File

@@ -3,6 +3,7 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
"SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,

View File

@@ -19,6 +19,7 @@ import subprocess
import av
from PIL import Image
from PIL.PngImagePlugin import PngInfo
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
@@ -115,6 +116,15 @@ def generate_av_fixture(
report(name)
def generate_png():
img = make_1x1_image()
info = PngInfo()
info.add_text('workflow', WORKFLOW_JSON)
info.add_text('prompt', PROMPT_JSON)
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
report('with_metadata.png')
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
@@ -167,6 +177,7 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
generate_webp()
generate_avif()
generate_flac()

View File

@@ -1,20 +0,0 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -1,291 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
const apiMock = vi.hoisted(
() =>
new (class extends EventTarget {
clientId: string | null = 'test-client'
getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] }))
subscribeLogs = vi.fn(async () => {})
})()
)
vi.mock('@/scripts/api', () => ({ api: apiMock }))
const terminalMock = vi.hoisted(() => ({
open: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
reset: vi.fn(),
scrollToBottom: vi.fn(),
onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}))
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: terminalMock,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => {
const { defineComponent, ref } = await import('vue')
const { useTerminal } =
await import('@/composables/bottomPanelTabs/useTerminal')
return {
default: defineComponent({
emits: ['created'],
setup(_, { emit }) {
const root = ref<HTMLElement | undefined>(undefined)
emit('created', useTerminal(root), root)
return () => null
}
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
logsTerminal: {
loadError:
'Unable to load logs, please ensure you have updated your ComfyUI backend.',
resyncError:
'Unable to resync logs after the backend reconnected. Reopen the console to retry.'
}
}
}
})
const renderLogsTerminal = () =>
render(LogsTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: { execution: { clientId: 'test-client' } }
}),
i18n
]
}
})
// Silence the production console.error calls in error-path tests. Vitest
// isolates this file's module graph so the spy does not affect other files.
vi.spyOn(console, 'error').mockImplementation(() => {})
// Resolve a getRawLogs call manually to drive deterministic timing in tests
// that need to observe behavior mid-fetch.
const deferredRawLogs = () => {
let resolve!: (value: { entries: { m: string }[] }) => void
let reject!: (err: unknown) => void
const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('LogsTerminal', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMock.clientId = 'test-client'
})
it('loads logs and subscribes to streaming on mount', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
expect(terminalMock.write).toHaveBeenCalledWith('log line\n')
})
})
it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1)
expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2)
expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true)
})
// The full sequence must be: reset -> write -> scroll -> subscribe
const resetOrder = terminalMock.reset.mock.invocationCallOrder[0]
const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)!
const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0]
const subscribeOrder =
apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)!
expect(resetOrder).toBeLessThan(writeOrder)
expect(writeOrder).toBeLessThan(scrollOrder)
expect(scrollOrder).toBeLessThan(subscribeOrder)
})
it('aborts an in-flight resync when a second "reconnected" arrives', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
// First resync hangs on getRawLogs
const first = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => first.promise)
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2)
})
// Second resync resolves immediately
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
})
// Now resolve the first (aborted) resync — none of its side effects must apply
first.resolve({ entries: [{ m: 'stale\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n')
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => {
// Mount's getRawLogs hangs so we can drive the race deterministically.
const mount = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => mount.promise)
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
// Resync wins the race and writes the post-reboot snapshot.
apiMock.getRawLogs.mockImplementationOnce(async () => ({
entries: [{ m: 'fresh\n' }]
}))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(terminalMock.reset).toHaveBeenCalledTimes(1)
expect(terminalMock.write).toHaveBeenCalledWith('fresh\n')
})
// Mount's late response must not stomp on the freshly-reset terminal.
mount.resolve({ entries: [{ m: 'stale-mount\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n')
})
it('surfaces an inline error when the resync fetch fails', async () => {
renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to resync logs')
})
})
it('shows a load error when the initial fetch fails', async () => {
apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom'))
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
})
it('recovers from an initial load failure when a reconnect arrives', async () => {
apiMock.getRawLogs
.mockRejectedValueOnce(new Error('initial fail'))
.mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] })
renderLogsTerminal()
await vi.waitFor(() => {
expect(
screen.getByTestId('terminal-error-message').textContent
).toContain('Unable to load logs')
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await vi.waitFor(() => {
expect(screen.queryByTestId('terminal-error-message')).toBeNull()
expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull()
expect(terminalMock.write).toHaveBeenCalledWith('recovered\n')
})
})
it('cleans up listeners and unsubscribes on unmount', async () => {
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true)
})
unmount()
await vi.waitFor(() => {
expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false)
})
apiMock.dispatchEvent(new CustomEvent('reconnected'))
await nextTick()
expect(terminalMock.reset).not.toHaveBeenCalled()
// No additional getRawLogs beyond the mount-time call
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
it('does not write to the terminal when unmount happens mid-fetch', async () => {
const pending = deferredRawLogs()
apiMock.getRawLogs.mockImplementationOnce(() => pending.promise)
const { unmount } = renderLogsTerminal()
await vi.waitFor(() => {
expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1)
})
unmount()
pending.resolve({ entries: [{ m: 'late\n' }] })
await nextTick()
await nextTick()
expect(terminalMock.write).not.toHaveBeenCalled()
})
})

View File

@@ -12,36 +12,79 @@
data-testid="terminal-loading-spinner"
class="relative inset-0 z-10 flex h-full items-center justify-center"
/>
<BaseTerminal
v-show="!loading && !errorMessage"
@created="terminalCreated"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import type { Terminal } from '@xterm/xterm'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { shallowRef } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import BaseTerminal from './BaseTerminal.vue'
const terminal = shallowRef<Terminal>()
const { errorMessage, loading } = useLogsTerminal(terminal)
const errorMessage = ref('')
const loading = ref(true)
const terminalCreated = (
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
// Auto-size terminal to fill container width.
// minCols: 80 ensures minimum width for colab environments.
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
terminal.value = instance
const update = (entries: Array<LogEntry>) => {
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
await api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
await watchLogs()
loading.value = false
})
onUnmounted(async () => {
if (api.clientId) {
await api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})
}
</script>

View File

@@ -10,7 +10,7 @@
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="onItemClick($event, item)"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
@@ -21,27 +21,20 @@
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu || item.isShapeSubmenu"
v-if="hasSubmenu || item.isColorSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
<SubmenuPopover
<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
v-if="colorOption"
ref="colorSubmenu"
key="color-submenu"
ref="colorPickerMenu"
key="color-picker-menu"
:option="colorOption"
@submenu-click="handleSubmenuSelect"
/>
<SubmenuPopover
v-if="shapeOption"
ref="shapeSubmenu"
key="shape-submenu"
:option="shapeOption"
@submenu-click="handleSubmenuSelect"
@submenu-click="handleColorSelect"
/>
</template>
@@ -61,18 +54,16 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SubmenuPopover from './selectionToolbox/SubmenuPopover.vue'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
isShapeSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const shapeSubmenu = ref<InstanceType<typeof SubmenuPopover>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
@@ -159,20 +150,21 @@ useEventListener(
{ passive: true }
)
// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)
const shapeOption = computed(() =>
menuOptions.value.find((opt) => opt.isShapePicker)
)
// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}
// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }
const isColor = Boolean(option.isColorPicker)
const isShape = Boolean(option.isShapePicker)
const usesPopover = isColor || isShape
const isColor = isColorOption(option)
const item: ExtendedMenuItem = {
label: option.label,
@@ -180,14 +172,11 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
isShapeSubmenu: isShape,
originalOption: option
}
// Submenus opened via popover (color, shape) deliberately omit `items` so
// PrimeVue does not render a nested <ul> inside the scrollable root list,
// which would be clipped when the menu overflows the viewport (FE-570).
if (option.hasSubmenu && option.submenu && !usesPopover) {
// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
@@ -199,6 +188,7 @@ function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
}))
}
// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
@@ -255,30 +245,17 @@ function toggle(event: Event) {
defineExpose({ toggle, hide, isOpen, show })
function onItemClick(event: MouseEvent, item: ExtendedMenuItem) {
if (item.isColorSubmenu) {
openSubmenuPopover(event, colorSubmenu.value, shapeSubmenu.value)
} else if (item.isShapeSubmenu) {
openSubmenuPopover(event, shapeSubmenu.value, colorSubmenu.value)
}
}
function openSubmenuPopover(
event: MouseEvent,
target: InstanceType<typeof SubmenuPopover> | undefined,
other: InstanceType<typeof SubmenuPopover> | undefined
) {
if (!target) return
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
other?.hide()
const anchor = Array.from((event.currentTarget as HTMLElement).children).find(
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
target.toggle(event, anchor)
colorPickerMenu.value?.toggle(event, target)
}
function handleSubmenuSelect(subOption: SubMenuOption) {
// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}
@@ -293,17 +270,11 @@ function constrainMenuHeight() {
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const availableHeight = window.innerHeight - rect.top - 8
if (availableHeight <= 0) return
// Setting overflow-y to auto/scroll on the root <ul> coerces overflow-x
// to a non-visible value too (CSS spec), which clips horizontally-opening
// submenus like Shape. Only apply the constraint when content truly
// overflows so the common case keeps overflow visible.
if (rootList.scrollHeight <= availableHeight) return
rootList.style.maxHeight = `${availableHeight}px`
rootList.style.overflowY = 'auto'
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {

View File

@@ -1,7 +1,6 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -30,26 +29,6 @@ function createMockExtensionService(): ReturnType<typeof useExtensionService> {
>
}
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
const defaultSettingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': true
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingValues = {
...defaultSettingValues,
...overrides
}
settingGetMock.mockImplementation(
(key: string): unknown => settingValues[key] ?? null
)
}
// Mock the composables and services
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
@@ -100,7 +79,10 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: settingGetMock
get: vi.fn((key: string) => {
if (key === 'Comfy.Load3D.3DViewerEnable') return true
return null
})
})
}))
@@ -146,7 +128,7 @@ describe('SelectionToolbox', () => {
}
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
@@ -157,7 +139,6 @@ describe('SelectionToolbox', () => {
canvasStore.canvas = createMockCanvas()
vi.resetAllMocks()
mockSettingValues()
})
function renderComponent(props = {}): { container: Element } {
@@ -250,42 +231,6 @@ describe('SelectionToolbox', () => {
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the new node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should not show info button when legacy menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
})
it('should show info button when new menu uses the legacy node library', () => {
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]

View File

@@ -16,8 +16,8 @@
@wheel="canvasInteractions.forwardEventToCanvas"
>
<DeleteButton v-if="showDelete" />
<VerticalDivider v-if="canOpenNodeInfo && showAnyPrimaryActions" />
<InfoButton v-if="canOpenNodeInfo" />
<VerticalDivider v-if="showInfoButton && showAnyPrimaryActions" />
<InfoButton v-if="showInfoButton" />
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
@@ -105,8 +105,9 @@ const {
isSingleImageNode,
hasAny3DNodeSelected,
hasOutputNodesSelected,
canOpenNodeInfo
nodeDef
} = useSelectionState()
const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)

View File

@@ -8,7 +8,7 @@
unstyled
:pt="{
root: {
class: 'p-popover absolute z-60'
class: 'absolute z-60'
},
content: {
class: [
@@ -90,12 +90,8 @@ const popoverRef = ref<InstanceType<typeof Popover>>()
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}
const hide = () => {
popoverRef.value?.hide()
}
defineExpose({
toggle,
hide
toggle
})
const handleSubmenuClick = (subOption: SubMenuOption) => {

View File

@@ -1,5 +1,6 @@
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'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,20 +9,19 @@ import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Button from '@/components/ui/button/Button.vue'
const { openNodeInfoMock, trackUiButtonClickedMock } = vi.hoisted(() => ({
openNodeInfoMock: vi.fn(),
trackUiButtonClickedMock: vi.fn()
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
openNodeInfo: openNodeInfoMock
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: trackUiButtonClickedMock
trackUiButtonClicked: vi.fn()
})
}))
@@ -39,8 +39,8 @@ describe('InfoButton', () => {
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
openNodeInfoMock.mockReturnValue(true)
})
const renderComponent = () => {
@@ -53,29 +53,12 @@ describe('InfoButton', () => {
})
}
const clickNodeInfoButton = async () => {
it('should open the info panel on click', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Node Info' }))
}
it('should open the node info panel on click', async () => {
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
})
})
it('should not track the click when the node info panel is unavailable', async () => {
openNodeInfoMock.mockReturnValue(false)
renderComponent()
await clickNodeInfoButton()
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).not.toHaveBeenCalled()
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -15,16 +15,18 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const { openNodeInfo } = useSelectionState()
const rightSidePanelStore = useRightSidePanelStore()
/**
* Track node info button click and toggle node help.
*/
const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
rightSidePanelStore.openPanel('info')
}
</script>

View File

@@ -0,0 +1,164 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
const commandStoreExecute = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
},
get isNightly() {
return distribution.isNightly
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: { discord: '', github: '' },
buildDocsUrl: () => 'https://docs.comfy.org'
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackHelpResourceClicked: vi.fn(),
trackHelpCenterOpened: vi.fn(),
trackHelpCenterClosed: vi.fn()
})
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
releases: [],
recentReleases: [],
isLoading: false,
fetchReleases: vi.fn().mockResolvedValue(undefined)
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: commandStoreExecute })
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => null
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
})
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({ isNewManagerUI: { value: false } })
}))
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: () => ({})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
default: defineComponent({
name: 'PuzzleIconStub',
render: () => h('div')
})
}))
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(HelpCenterMenuContent, {
global: {
plugins: [i18n]
}
})
return { user, ...result }
}
describe('HelpCenterMenuContent feedback item', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
commandStoreExecute.mockReset()
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
cleanup()
})
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
distribution.isNightly = true
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
'_blank',
'noopener,noreferrer'
)
expect(commandStoreExecute).not.toHaveBeenCalled()
})
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
expect(openSpy).not.toHaveBeenCalled()
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})

View File

@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
trackResourceClick('help_feedback', isCloud || isNightly)
if (isCloud || isNightly) {
window.open(
'https://form.typeform.com/to/q7azbWPi',
buildFeedbackTypeformUrl('help-center'),
'_blank',
'noopener,noreferrer'
)

View File

@@ -12,7 +12,7 @@
</span>
<span
v-if="rest"
class="-ml-2.5 flex h-5 max-w-max min-w-0 grow basis-0 items-center truncate rounded-r-full bg-component-node-widget-background text-xs"
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
>
<span class="pr-2" v-text="rest" />
</span>

View File

@@ -2,7 +2,6 @@
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
data-testid="node-preview-card"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div

View File

@@ -0,0 +1,334 @@
import { fireEvent, render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
const sizeHolder = vi.hoisted(() => ({ width: 0, height: 0 }))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useElementSize: () => ({
width: ref(sizeHolder.width),
height: ref(sizeHolder.height)
})
}
})
const painterHolder = vi.hoisted(() => ({
state: null as Record<string, unknown> | null
}))
function createDefaultPainterState() {
return {
tool: ref('brush'),
brushSize: ref(20),
brushColor: ref('#000000'),
brushOpacity: ref(1),
brushHardness: ref(1),
backgroundColor: ref('#ffffff'),
canvasWidth: ref(512),
canvasHeight: ref(512),
cursorVisible: ref(true),
displayBrushSize: ref(20),
inputImageUrl: ref<string | null>(null),
isImageInputConnected: ref(false),
handlePointerDown: vi.fn(),
handlePointerMove: vi.fn(),
handlePointerUp: vi.fn(),
handlePointerEnter: vi.fn(),
handlePointerLeave: vi.fn(),
handleInputImageLoad: vi.fn(),
handleClear: vi.fn()
}
}
vi.mock('@/composables/painter/usePainter', () => ({
PAINTER_TOOLS: { BRUSH: 'brush', ERASER: 'eraser' } as const,
usePainter: () => {
if (!painterHolder.state) painterHolder.state = createDefaultPainterState()
return painterHolder.state
}
}))
import WidgetPainter from './WidgetPainter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
painter: {
tool: 'Tool',
brush: 'Brush',
eraser: 'Eraser',
size: 'Size',
color: 'Color',
hardness: 'Hardness',
width: 'Width',
height: 'Height',
background: 'Background',
clear: 'Clear'
}
}
}
})
const ButtonStub = defineComponent({
name: 'Button',
inheritAttrs: false,
template: '<button v-bind="$attrs" type="button"><slot /></button>'
})
const SliderStub = defineComponent({
name: 'Slider',
props: {
modelValue: { type: Array, default: () => [] },
min: Number,
max: Number,
step: Number
},
emits: ['update:modelValue'],
template:
'<div data-testid="slider-stub" :data-min="min" @click="$emit(\'update:modelValue\', [Number(min) + Number(step ?? 1)])" />'
})
function primePainterState(overrides: Record<string, unknown> = {}) {
painterHolder.state = { ...createDefaultPainterState(), ...overrides }
}
function renderWidget(initialModel = '') {
const value = ref(initialModel)
const Harness = defineComponent({
components: { WidgetPainter },
setup: () => ({ value }),
template: '<WidgetPainter v-model="value" node-id="42" />'
})
return render(Harness, {
global: {
plugins: [i18n],
stubs: { Button: ButtonStub, Slider: SliderStub }
}
})
}
describe('WidgetPainter', () => {
beforeEach(() => {
sizeHolder.width = 0
sizeHolder.height = 0
painterHolder.state = null
})
describe('Label visibility', () => {
const allLabels = [
'Tool',
'Size',
'Color',
'Hardness',
'Width',
'Height',
'Background'
]
it('renders every label in wide layout (width >= 350)', () => {
sizeHolder.width = 600
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('still renders every label in compact layout (width < 350)', () => {
sizeHolder.width = 200
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('keeps labels at the responsive boundary (width = 350)', () => {
sizeHolder.width = 350
primePainterState()
renderWidget()
for (const label of allLabels) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
})
describe('Image-input branch', () => {
it('hides canvas-size and background controls when an image is connected', () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
expect(screen.queryByText('Width')).toBeNull()
expect(screen.queryByText('Height')).toBeNull()
expect(screen.queryByText('Background')).toBeNull()
expect(screen.getByTestId('painter-dimension-text')).toBeInTheDocument()
})
it('renders the input image inside the canvas container', () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
const container = screen.getByTestId('painter-canvas-container')
expect(within(container).getByRole('img')).toBeInTheDocument()
})
})
describe('Tool selection', () => {
it('hides brush-only controls when the eraser tool is active', () => {
primePainterState({ tool: ref('eraser') })
renderWidget()
expect(screen.queryByText('Color')).toBeNull()
expect(screen.queryByText('Hardness')).toBeNull()
})
it('updates the active tool when clicking brush/eraser buttons', async () => {
const tool = ref<'brush' | 'eraser'>('brush')
primePainterState({ tool })
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByText('Eraser'))
expect(tool.value).toBe('eraser')
await user.click(screen.getByText('Brush'))
expect(tool.value).toBe('brush')
})
})
describe('Canvas events', () => {
it('forwards pointerdown/up to the composable on click', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('painter-canvas'))
const s = painterHolder.state!
expect(s.handlePointerDown).toHaveBeenCalled()
expect(s.handlePointerUp).toHaveBeenCalled()
})
it('forwards pointerenter/leave to the composable on hover', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
const canvas = screen.getByTestId('painter-canvas')
await user.hover(canvas)
await user.unhover(canvas)
const s = painterHolder.state!
expect(s.handlePointerEnter).toHaveBeenCalled()
expect(s.handlePointerLeave).toHaveBeenCalled()
})
it('invokes handleInputImageLoad when the input image fires load', async () => {
primePainterState({
isImageInputConnected: ref(true),
inputImageUrl: ref('/img.png')
})
renderWidget()
const img = within(
screen.getByTestId('painter-canvas-container')
).getByRole('img')
await fireEvent.load(img)
expect(painterHolder.state!.handleInputImageLoad).toHaveBeenCalled()
})
})
describe('Control bindings', () => {
it('invokes handleClear when the clear button is clicked', async () => {
primePainterState()
renderWidget()
const user = userEvent.setup()
await user.click(screen.getByTestId('painter-clear-button'))
expect(painterHolder.state!.handleClear).toHaveBeenCalled()
})
it('updates brushSize via the size slider', async () => {
const brushSize = ref(20)
primePainterState({ brushSize })
renderWidget()
const user = userEvent.setup()
const slider = within(screen.getByTestId('painter-size-row')).getByTestId(
'slider-stub'
)
await user.click(slider)
expect(brushSize.value).toBe(2) // min=1, step=1 -> emits 2
})
it('updates brushColor via the color picker', async () => {
const brushColor = ref('#000000')
primePainterState({ brushColor })
renderWidget()
const colorInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('#000000')
// <input type="color"> has no userEvent equivalent — fire input directly
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(colorInput, { target: { value: '#ff0000' } })
expect(brushColor.value.toLowerCase()).toBe('#ff0000')
})
it('updates brushOpacity via the percent input', async () => {
const brushOpacity = ref(1)
primePainterState({ brushOpacity })
renderWidget()
const user = userEvent.setup()
const percentInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('100')
await user.clear(percentInput)
await user.type(percentInput, '50')
await user.tab() // blur to trigger @change
expect(brushOpacity.value).toBeCloseTo(0.5)
})
it('clamps opacity input to the 0-100 range', async () => {
const brushOpacity = ref(1)
primePainterState({ brushOpacity })
renderWidget()
const user = userEvent.setup()
const percentInput = within(
screen.getByTestId('painter-color-row')
).getByDisplayValue('100')
await user.clear(percentInput)
await user.type(percentInput, '999')
await user.tab()
expect(brushOpacity.value).toBe(1) // clamped to 100% -> 1.0
})
it('updates background color via the bg color input', async () => {
const backgroundColor = ref('#ffffff')
primePainterState({ backgroundColor })
renderWidget()
const bgInput = within(
screen.getByTestId('painter-bg-color-row')
).getByDisplayValue('#ffffff')
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.input(bgInput, { target: { value: '#00ff00' } })
expect(backgroundColor.value.toLowerCase()).toBe('#00ff00')
})
})
})

View File

@@ -23,6 +23,7 @@
/>
<canvas
ref="canvasEl"
data-testid="painter-canvas"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@@ -58,7 +59,6 @@
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
@@ -99,7 +99,6 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
@@ -126,7 +125,6 @@
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
@@ -170,7 +168,6 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
@@ -199,7 +196,6 @@
<template v-if="!isImageInputConnected">
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
@@ -222,7 +218,6 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
@@ -245,7 +240,6 @@
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
@@ -21,13 +21,19 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}
}))
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
elementWidth: ref(100),
isOutside: ref(false)
})
}))
const mockRect = (el: HTMLElement, width: number) => {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: width,
bottom: 100,
width,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
}
describe('CompareSliderThumbnail', () => {
const renderThumbnail = (props = {}) => {
@@ -74,4 +80,44 @@ describe('CompareSliderThumbnail', () => {
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
it('updates slider position on mousemove', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('25%')
})
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: -10 } })
let divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('0%')
await user.pointer({ target: container, coords: { clientX: 250 } })
divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('100%')
})
it('ignores mousemove when container has zero width', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 0)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
})

View File

@@ -9,7 +9,11 @@
: 'max-w-full max-h-64 object-contain'
"
/>
<div ref="containerRef" class="absolute inset-0">
<div
data-testid="compare-slider-container"
class="absolute inset-0"
@mousemove="updateSliderPosition"
>
<LazyImage
:src="overlayImageSrc"
:alt="alt"
@@ -34,8 +38,7 @@
</template>
<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
@@ -57,18 +60,20 @@ const isVideoType =
false
const sliderPosition = ref(SLIDER_START_POSITION)
const containerRef = ref<HTMLElement | null>(null)
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
// Update slider position based on mouse position when hovered
watch(
[() => isHovered, elementX, elementWidth, isOutside],
([isHovered, x, width, outside]) => {
if (!isHovered) return
if (!outside) {
sliderPosition.value = (x / width) * 100
}
}
)
/**
* Update slider position from a local mousemove. Scoped to currentTarget so
* only the hovered card reads its rect — unlike useMouseInElement which
* attaches a global mousemove listener and fires for every mounted instance.
*/
function updateSliderPosition(event: MouseEvent) {
const el = event.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width === 0) return
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
// push the raw percentage slightly out of range, which would offset the
// divider past the container or invert the overlay's clipPath.
const raw = ((event.clientX - rect.left) / rect.width) * 100
sliderPosition.value = Math.max(0, Math.min(100, raw))
}
</script>

View File

@@ -252,20 +252,6 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('Log Out')).toBeInTheDocument()
})
describe('credits help icon (FE-617)', () => {
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
renderComponent()
const helpButton = screen.getByTestId('credits-info-button')
expect(helpButton).toBeInTheDocument()
expect(helpButton.tagName).toBe('BUTTON')
expect(helpButton).toHaveAttribute(
'aria-label',
enMessages.credits.unified.tooltip
)
})
})
it('opens user settings and emits close event when settings item is clicked', async () => {
const { user, onClose } = renderComponent()

View File

@@ -41,16 +41,10 @@
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
<Button
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon-sm"
class="mr-auto"
:aria-label="$t('credits.unified.tooltip')"
data-testid="credits-info-button"
>
<i class="icon-[lucide--circle-help]" />
</Button>
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
/>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"

View File

@@ -0,0 +1,186 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import WorkflowTabs from './WorkflowTabs.vue'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
},
get isNightly() {
return distribution.isNightly
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: { value: false } })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
isOverflowing: { value: false },
disposed: { value: false },
checkOverflow: vi.fn(),
dispose: vi.fn()
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
openWorkflow: vi.fn(),
closeWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () =>
reactive({
openWorkflows: [],
activeWorkflow: null
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/utils/mouseDownUtil', () => ({
whileMouseDown: vi.fn()
}))
vi.mock('./WorkflowOverflowMenu.vue', () => ({
default: defineComponent({
name: 'WorkflowOverflowMenuStub',
render: () => h('div')
})
}))
vi.mock('./WorkflowTab.vue', () => ({
default: defineComponent({
name: 'WorkflowTabStub',
render: () => h('div')
})
}))
vi.mock('./CurrentUserButton.vue', () => ({
default: defineComponent({
name: 'CurrentUserButtonStub',
render: () => h('div')
})
}))
vi.mock('./LoginButton.vue', () => ({
default: defineComponent({
name: 'LoginButtonStub',
render: () => h('div')
})
}))
function renderComponent() {
const user = userEvent.setup()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(WorkflowTabs, {
global: {
plugins: [i18n],
directives: {
tooltip: {}
}
}
})
return { user, ...result }
}
describe('WorkflowTabs feedback button', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
})
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
distribution.isCloud = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
'_blank',
'noopener,noreferrer'
)
})
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
distribution.isNightly = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Feedback' }))
expect(openSpy).toHaveBeenCalledWith(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
'_blank',
'noopener,noreferrer'
)
})
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
renderComponent()
expect(
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
it('does not render the feedback button when the legacy tab bar is active', () => {
distribution.isCloud = true
tabBarLayout.value = 'Legacy'
renderComponent()
expect(
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
})

View File

@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -152,9 +152,12 @@ const isIntegratedTabBar = computed(
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
window.open(
buildFeedbackTypeformUrl('topbar'),
'_blank',
'noopener,noreferrer'
)
}
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -1,123 +0,0 @@
import { until, useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref } from 'vue'
import { onMounted, onScopeDispose, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
type TerminalLike = {
write: (data: string) => void
reset: () => void
scrollToBottom: () => void
}
/**
* Drives the built-in logs terminal: initial load, live `logs` stream, and
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
*
* Listeners are registered synchronously so we cannot miss a `reconnected`
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
* tied to AbortControllers so that:
* - rapid double-reconnects don't interleave writes / double-subscribe
* - unmount mid-fetch never writes to a disposed terminal
*/
export function useLogsTerminal(
terminal: Readonly<Ref<TerminalLike | undefined>>
) {
const { t } = useI18n()
const errorMessage = ref('')
const loading = ref(true)
let mountController: AbortController | undefined
let resyncController: AbortController | undefined
const writeEntries = (entries: LogEntry[]) => {
terminal.value?.write(entries.map((e) => e.m).join(''))
}
const resyncLogs = async () => {
// Cancel both the in-flight mount fetch and any prior resync so a late
// mount response can't write a stale snapshot on top of a freshly-reset
// terminal after we've already written the post-reconnect view.
mountController?.abort()
resyncController?.abort()
const controller = new AbortController()
resyncController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
terminal.value.reset()
writeEntries(logs.entries)
terminal.value.scrollToBottom()
// Backend lost the per-client log subscription across the restart;
// re-subscribe so new runtime logs stream over the fresh WebSocket.
await api.subscribeLogs(true)
if (signal.aborted) return
errorMessage.value = ''
loading.value = false
} catch (err) {
if (signal.aborted) return
console.error('Error resyncing logs after reconnect', err)
errorMessage.value = t('logsTerminal.resyncError')
}
}
// Register listeners synchronously, before any awaits, so a reconnect
// fired during mount cannot be missed. useEventListener handles cleanup
// on scope dispose.
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
writeEntries(e.detail.entries)
})
useEventListener(api, 'reconnected', () => {
void resyncLogs()
})
onMounted(async () => {
if (!terminal.value) await until(terminal).toBeTruthy()
const controller = new AbortController()
mountController = controller
const { signal } = controller
try {
const logs = await api.getRawLogs()
if (signal.aborted || !terminal.value) return
writeEntries(logs.entries)
} catch (err) {
if (signal.aborted) return
console.error('Error loading logs', err)
errorMessage.value = t('logsTerminal.loadError')
loading.value = false
return
}
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) await until(clientId).not.toBeNull()
if (signal.aborted) return
try {
await api.subscribeLogs(true)
} catch (err) {
if (signal.aborted) return
console.error('Error subscribing to logs', err)
}
if (!signal.aborted) loading.value = false
})
onScopeDispose(() => {
mountController?.abort()
resyncController?.abort()
if (!api.clientId) return
api.subscribeLogs(false).catch((err) => {
console.error('Error unsubscribing from logs', err)
})
})
return { errorMessage, loading }
}

View File

@@ -21,12 +21,6 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
beforeEach(() => {
vi.restoreAllMocks()
})
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
@@ -211,47 +205,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears missing media when an upload emits onWidgetChanged', () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
node.type = 'LoadImage'
const widget = node.addWidget(
'combo',
'image',
'missing.png',
() => undefined,
{ values: [] }
)
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'missing.png',
isMissing: true
} satisfies MissingMediaCandidate
])
node.onWidgetChanged!.call(
node,
'image',
'uploaded.png',
'missing.png',
widget
)
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
@@ -394,90 +347,6 @@ describe('installErrorClearingHooks lifecycle', () => {
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
it('scans added-node missing models after widget values are restored', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
values: []
})
graph.add(node)
widget.value = 'fake_model.safetensors'
await Promise.resolve()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
})
it('scans added-node missing models before the deferred media scan', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const modelScan = vi
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockImplementation((_rootGraph, node) => [
{
nodeId: String(node.id),
nodeType: node.type,
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
} satisfies MissingModelCandidate
])
const mediaScan = vi
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
.mockReturnValue([])
installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.type = 'CheckpointLoaderSimple'
graph.add(node)
await Promise.resolve()
expect(modelScan).toHaveBeenCalledOnce()
expect(useMissingModelStore().missingModelCandidates).toEqual([
expect.objectContaining({ name: 'fake_model.safetensors' })
])
expect(mediaScan).not.toHaveBeenCalled()
await Promise.resolve()
expect(mediaScan).toHaveBeenCalledTimes(1)
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
mediaScan.mock.invocationCallOrder[0]
)
})
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
installErrorClearingHooks(graph)
const node = new LGraphNode('LoadVideo')
node.type = 'LoadVideo'
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
values: []
})
graph.add(node)
await Promise.resolve()
node.isUploading = true
await Promise.resolve()
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
expect(mediaScan).toHaveBeenCalledOnce()
})
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
@@ -674,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -742,6 +611,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
describe('realtime verification staleness guards', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
@@ -816,7 +686,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
@@ -901,6 +771,7 @@ describe('realtime verification staleness guards', () => {
describe('scan skips interior of bypassed subgraph containers', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})

View File

@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyMediaCandidates
verifyCloudMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -155,26 +155,25 @@ function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
function scanNodeErrorTargets(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (innerNode.isSubgraphNode?.()) continue
if (isNodeInactive(innerNode.mode)) continue
scanNode(innerNode)
scanSingleNodeErrors(innerNode)
}
return
}
scanNode(node)
scanSingleNodeErrors(node)
}
function getActiveExecutionId(node: LGraphNode): string | null {
if (!app.rootGraph) return null
function scanSingleNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
// Skip when any enclosing subgraph is muted/bypassed. Callers only
// verify each node's own mode; entering a bypassed subgraph (via
// useGraphNodeManager replaying onNodeAdded for existing interior
@@ -182,25 +181,7 @@ function getActiveExecutionId(node: LGraphNode): string | null {
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
return execId
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
scanNodeErrorTargets(node, scanSingleNodeErrors)
}
function scanSingleNodeErrors(node: LGraphNode): void {
scanSingleNodeModelsAndTypes(node)
scanSingleNodeMedia(node)
}
function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
if (!app.rootGraph) return
const execId = getActiveExecutionId(node)
if (!execId) return
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
@@ -223,40 +204,39 @@ function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
void verifyAndAddPendingModels(pendingModels)
}
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
function scanSingleNodeMedia(node: LGraphNode): void {
if (!app.rootGraph) return
if (!getActiveExecutionId(node)) return
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans return pending for asset verification. OSS scans only
// return pending for generated output media.
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
// Check for missing node type
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const execId = getExecutionIdByNode(app.rootGraph, node)
if (execId) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
}
/**
@@ -302,7 +282,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyMediaCandidates(pending, { isCloud })
await verifyCloudMediaCandidates(pending)
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
@@ -313,23 +293,10 @@ async function verifyAndAddPendingMedia(
}
}
function scanAddedNode(
node: LGraphNode,
scanNode: (node: LGraphNode) => void
): void {
function scanAddedNode(node: LGraphNode): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
scanNodeErrorTargets(node, scanNode)
}
function scheduleAddedNodeScan(node: LGraphNode): void {
queueMicrotask(() => {
scanAddedNode(node, scanSingleNodeModelsAndTypes)
// Paste/drop upload handlers run immediately after graph.add and must set
// node.isUploading synchronously before their first await. This second
// microtask lets that upload state settle before media widgets are scanned.
queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
})
scanAndAddNodeErrors(node)
}
function handleNodeModeChange(
@@ -401,12 +368,10 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
// Model and node scans use the original one-microtask deferral so pasted
// missing-model errors appear before selection-scoped tabs recalculate.
// Media gets one extra microtask so drag/drop upload handlers can mark
// transient upload state before media detection reads the widget value.
// Deferred to microtask because onNodeAdded fires before
// node.configure() restores widget values.
if (!ChangeTracker.isLoadingGraph) {
scheduleAddedNodeScan(node)
queueMicrotask(() => scanAddedNode(node))
}
originalOnNodeAdded?.call(this, node)

View File

@@ -48,8 +48,6 @@ export interface WidgetSlotMetadata {
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
@@ -109,7 +107,7 @@ export interface VueNodeData {
title: string
type: string
apiNode?: boolean
badges?: Badges
badges?: (LGraphBadge | (() => LGraphBadge))[]
bgcolor?: string
color?: string
flags?: {
@@ -788,12 +786,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
showAdvanced: Boolean(propertyEvent.newValue)
})
break
case 'badges':
vueNodeData.set(nodeId, {
...currentData,
badges: propertyEvent.newValue as Badges
})
break
}
}
},

View File

@@ -33,7 +33,6 @@ export interface MenuOption {
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
isShapePicker?: boolean
}
export interface SubMenuOption {
@@ -125,8 +124,8 @@ export function useMoreOptionsMenu() {
const {
selectedItems,
selectedNodes,
canOpenNodeInfo,
openNodeInfo,
nodeDef,
showNodeHelp,
hasSubgraphs: hasSubgraphsComputed,
hasImageNode,
hasOutputNodesSelected,
@@ -244,8 +243,8 @@ export function useMoreOptionsMenu() {
options.push({ type: 'divider' })
// Section 4: Node properties (Node Info, Shape, Color)
if (canOpenNodeInfo.value) {
options.push(getNodeInfoOption(openNodeInfo))
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
}
if (groupContext) {
options.push(getGroupColorOptions(groupContext, bump))

View File

@@ -66,7 +66,6 @@ export function useNodeMenuOptions() {
icon: 'icon-[lucide--box]',
hasSubmenu: true,
submenu: shapeSubmenu.value,
isShapePicker: true,
action: () => {}
},
{
@@ -112,10 +111,10 @@ export function useNodeMenuOptions() {
action: runBranch
})
const getNodeInfoOption = (openNodeInfo: () => boolean): MenuOption => ({
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
label: t('contextMenu.Node Info'),
icon: 'icon-[lucide--info]',
action: openNodeInfo
action: showNodeHelp
})
return {

View File

@@ -3,11 +3,9 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
@@ -15,6 +13,11 @@ import {
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
// Mock composables
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
@@ -36,45 +39,6 @@ const mockConnection = {
isNode: false
}
function createMockNodeDef() {
return new ComfyNodeDefImpl({
name: 'TestNode',
display_name: 'Test Node',
category: 'test',
input: {},
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'nodes',
description: ''
})
}
function selectSingleNodeWithNodeDef(id: number) {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id, type: 'TestNode' })
]
vi.mocked(nodeDefStore.fromLGraphNode).mockReturnValue(createMockNodeDef())
}
function mockSettingValues(overrides: Record<string, unknown> = {}) {
const settingStore = useSettingStore()
const settingValues: Record<string, unknown> = {
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': true,
'Comfy.Load3D.3DViewerEnable': false,
...overrides
}
vi.mocked(settingStore.get).mockImplementation(
(key: string): unknown => settingValues[key]
)
}
describe('useSelectionState', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -85,7 +49,14 @@ describe('useSelectionState', () => {
createSpy: vi.fn
})
)
mockSettingValues()
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as ReturnType<typeof useNodeLibrarySidebarTab>)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
@@ -216,83 +187,4 @@ describe('useSelectionState', () => {
expect(newIsPinned).toBe(false)
})
})
describe('Node Info', () => {
test('should open the right side info panel for a selected node', () => {
const rightSidePanelStore = useRightSidePanelStore()
selectSingleNodeWithNodeDef(8)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
openNodeInfo()
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open the right side panel for multiple selected nodes', () => {
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.$state.selectedItems = [
createMockLGraphNode({ id: 9, type: 'TestNode' }),
createMockLGraphNode({ id: 10, type: 'TestNode' })
]
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
openNodeInfo()
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should open the right side info panel when new menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Top',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(11)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(true)
const didOpen = openNodeInfo()
expect(didOpen).toBe(true)
expect(rightSidePanelStore.openPanel).toHaveBeenCalledWith('info')
})
test('should not open node info when legacy menu uses the new node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': true
})
selectSingleNodeWithNodeDef(12)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
test('should not open node info when legacy menu uses the legacy node library', () => {
const rightSidePanelStore = useRightSidePanelStore()
mockSettingValues({
'Comfy.UseNewMenu': 'Disabled',
'Comfy.NodeLibrary.NewDesign': false
})
selectSingleNodeWithNodeDef(13)
const { canOpenNodeInfo, openNodeInfo } = useSelectionState()
expect(canOpenNodeInfo.value).toBe(false)
const didOpen = openNodeInfo()
expect(didOpen).toBe(false)
expect(rightSidePanelStore.openPanel).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,12 +1,14 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
@@ -23,8 +25,9 @@ export interface NodeSelectionState {
export function useSelectionState() {
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const { selectedItems } = storeToRefs(canvasStore)
@@ -61,7 +64,7 @@ export function useSelectionState() {
)
const hasAny3DNodeSelected = computed(() => {
const enable3DViewer = settingStore.get('Comfy.Load3D.3DViewerEnable')
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
return (
selectedNodes.value.length === 1 &&
selectedNodes.value.some(isLoad3dNode) &&
@@ -95,24 +98,34 @@ export function useSelectionState() {
const computeSelectionFlags = (): NodeSelectionState =>
computeSelectionStatesFromNodes(selectedNodes.value)
const canOpenNodeInfo = computed(
() =>
Boolean(nodeDef.value) &&
settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
/** Toggle node help sidebar/panel for the single selected node (if any). */
const showNodeHelp = () => {
const def = nodeDef.value
if (!def) return
const openNodeInfo = () => {
if (!canOpenNodeInfo.value) return false
rightSidePanelStore.openPanel('info')
return true
const isSidebarActive =
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
const currentHelpNode = nodeHelpStore.currentHelpNode
const isSameNodeHelpOpen =
isSidebarActive &&
nodeHelpStore.isHelpOpen &&
currentHelpNode?.nodePath === def.nodePath
if (isSameNodeHelpOpen) {
nodeHelpStore.closeHelp()
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
return
}
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
nodeHelpStore.openHelp(def)
}
return {
selectedItems,
selectedNodes,
nodeDef,
canOpenNodeInfo,
openNodeInfo,
showNodeHelp,
hasAny3DNodeSelected,
hasAnySelection,
hasSingleSelection,

View File

@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
})
}
function createFile(name = 'test.png', type = 'image/png'): File {
return new File(['data'], name, { type })
function createFile(name = 'test.png'): File {
return new File(['data'], name, { type: 'image/png' })
}
function successResponse(name: string, subfolder?: string) {
@@ -95,21 +95,15 @@ describe('useNodeImageUpload', () => {
})
})
it.for([
{ mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
{ mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
])(
'sets isUploading true during $mediaType upload and false after',
async ({ filename, mimeType }) => {
mockFetchApi.mockResolvedValueOnce(successResponse(filename))
it('sets isUploading true during upload and false after', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const promise = capturedDragOnDrop([createFile(filename, mimeType)])
expect(node.isUploading).toBe(true)
const promise = capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(true)
await promise
expect(node.isUploading).toBe(false)
}
)
await promise
expect(node.isUploading).toBe(false)
})
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))

View File

@@ -625,9 +625,9 @@ describe('useNodePricing', () => {
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
// VueNodes path bumps per-node ref and the global tick.
// VueNodes path bumps per-node ref instead of the global tick.
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
expect(pricingRevision.value).toBe(tickBefore)
} finally {
LiteGraph.vueNodesMode = false
}

View File

@@ -509,8 +509,10 @@ const scheduleEvaluation = (
if (LiteGraph.vueNodesMode) {
// VueNodes mode: bump per-node revision (only this node re-renders)
getNodeRevisionRef(node.id).value++
} else {
// Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas
pricingTick.value++
}
pricingTick.value++
})
inflight.set(node, { sig, promise })

View File

@@ -18,15 +18,6 @@ export const usePriceBadge = () => {
} else {
node.badges.push(...newBadges)
}
const graph = node.graph
if (!graph) return
graph.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'badges',
oldValue: node.badges,
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionBarButton } from '@/types/comfy'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const registerExtension = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('cloudFeedbackTopbarButton', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
registerExtension.mockReset()
distribution.isCloud = false
distribution.isNightly = false
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
})
function getRegisteredButtons(): ActionBarButton[] {
expect(registerExtension).toHaveBeenCalledTimes(1)
const extension = registerExtension.mock.calls[0]?.[0] as {
actionBarButtons: ActionBarButton[]
}
return extension.actionBarButtons
}
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
tabBarLayout.value = 'Legacy'
distribution.isCloud = true
await import('./cloudFeedbackTopbarButton')
const buttons = getRegisteredButtons()
expect(buttons).toHaveLength(1)
buttons[0].onClick?.()
expect(openSpy).toHaveBeenCalledTimes(1)
const [url, target, features] = openSpy.mock.calls[0]
expect(url).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
})
it('only registers the action bar button when the tab bar is Legacy', async () => {
tabBarLayout.value = 'Default'
await import('./cloudFeedbackTopbarButton')
expect(getRegisteredButtons()).toEqual([])
})
})

View File

@@ -1,17 +1,20 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
window.open(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
}
}
]

View File

@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
const {
registerExtensionMock,
waitForLoad3dMock,
configureMock,
getLoad3dMock,
toastAddAlertMock
} = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
configureMock: vi.fn(),
getLoad3dMock: vi.fn(),
toastAddAlertMock: vi.fn()
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({
getLoad3d: getLoad3dMock,
handleViewerClose: vi.fn()
})
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
nodeToLoad3dMap: new Map()
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configure = configureMock
}
}))
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn((p: string) => ['', p]),
getResourceURL: vi.fn(() => '/view'),
uploadFile: vi.fn(),
uploadMultipleFiles: vi.fn(),
uploadTempImage: vi.fn()
}
}))
vi.mock('@/extensions/core/load3d/constants', () => ({
SUPPORTED_EXTENSIONS_ACCEPT: '.glb,.gltf'
}))
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ default: {} }))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn(),
addWidget: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL: (p: string) => p }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: {} } },
ComfyApp: { copyToClipspace: vi.fn(), clipspace_return_node: null }
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: toastAddAlertMock })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog: vi.fn() })
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLoad3dNode: vi.fn(() => true)
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: { ContextMenu: vi.fn() }
}))
type ExtCreated = ComfyExtension & {
nodeCreated: (node: LGraphNode) => Promise<void>
beforeRegisterNodeDef: (
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) => Promise<void>
getNodeMenuItems: (node: LGraphNode) => unknown[]
}
async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
}
}
interface FakeWidget {
name: string
value: unknown
serializeValue?: () => Promise<unknown>
}
function makePreview3DNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3D' },
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
function makeLoad3DNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
size: [300, 600],
setSize: vi.fn(),
widgets: overrides.widgets ?? [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },
{ name: 'height', value: 512 },
{ name: 'image', value: '' }
],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
interface FakeLoad3d {
whenLoadIdle: () => Promise<void>
setCameraFromMatrices: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
isSplatModel: ReturnType<typeof vi.fn>
currentLoadGeneration: number
}
function makeLoad3dMock(): FakeLoad3d {
return {
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
setCameraFromMatrices: vi.fn(),
setBackgroundImage: vi.fn(),
isSplatModel: vi.fn(() => false),
currentLoadGeneration: 0
}
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
function setupBaseMocks() {
vi.clearAllMocks()
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
}
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
})
})
describe('Comfy.Preview3D.beforeRegisterNodeDef', () => {
beforeEach(setupBaseMocks)
it('rewrites the image input spec for Preview3D nodes', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const nodeData = {
name: 'Preview3D',
input: { required: { image: ['STRING', {}] } }
} as unknown as ComfyNodeDef
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
expect(nodeData.input!.required!.image).toEqual(['PREVIEW_3D'])
})
it('leaves non-Preview3D node defs unchanged', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const nodeData = {
name: 'Load3D',
input: { required: { image: ['STRING', {}] } }
} as unknown as ComfyNodeDef
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
expect(nodeData.input!.required!.image).toEqual(['STRING', {}])
})
})
describe('Comfy.Preview3D.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Preview3D', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const node = makePreview3DNode({ comfyClass: 'OtherNode' })
await preview3DExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureMock).not.toHaveBeenCalled()
})
it('does not configure on creation when no Last Time Model File is persisted', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
expect(configureMock).not.toHaveBeenCalled()
})
it('restores via configure with persisted cameraState when Last Time Model File is set', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const cameraState = { position: [1, 2, 3] }
const node = makePreview3DNode({
properties: {
'Last Time Model File': 'prev/model.glb',
'Camera Config': { cameraType: 'perspective', state: cameraState }
}
})
await preview3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'output',
modelWidget: expect.objectContaining({ value: 'prev/model.glb' }),
cameraState,
silentOnNotFound: true
})
})
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
expect(configureMock).toHaveBeenCalledWith(
expect.objectContaining({
loadFolder: 'output',
silentOnNotFound: true
})
)
})
it('forwards bgImagePath to load3d.setBackgroundImage on execute', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb', undefined, 'bg.png'] })
expect(load3d.setBackgroundImage).toHaveBeenCalledWith('bg.png')
})
it('applies camera matrices when load3d generation is unchanged', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const extrinsics = [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
const intrinsics = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1]
]
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, undefined, extrinsics, intrinsics]
})
await flush()
expect(load3d.setCameraFromMatrices).toHaveBeenCalledWith(
extrinsics,
intrinsics
)
})
it('skips camera matrix application when load3d generation changes before whenLoadIdle resolves', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
let resolveIdle: () => void = () => {}
load3d.whenLoadIdle = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveIdle = resolve
})
)
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, undefined, [[1]], [[1]]]
})
load3d.currentLoadGeneration = 6
resolveIdle()
await flush()
expect(load3d.setCameraFromMatrices).not.toHaveBeenCalled()
})
it('shows an error toast when onExecuted has no file path', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const node = makePreview3DNode()
await preview3DExt.nodeCreated(node)
node.onExecuted!({ result: [] })
expect(toastAddAlertMock).toHaveBeenCalledWith(
'toastMessages.unableToGetModelFilePath'
)
})
})
describe('Comfy.Load3D.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Load3D', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode({ comfyClass: 'OtherNode' })
await load3DExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
})
it('configures with the input folder and width/height widgets', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'model.glb' },
{ name: 'width', value: 1024 },
{ name: 'height', value: 768 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2]
})
})
it('attaches a serializeValue function to the scene widget', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },
{ name: 'height', value: 512 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(typeof widgets[3].serializeValue).toBe('function')
})
it('skips configure when required widgets are missing', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode({
widgets: [{ name: 'model_file', value: '' }]
})
await load3DExt.nodeCreated(node)
expect(configureMock).not.toHaveBeenCalled()
})
})
describe('getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
it('Comfy.Load3D returns [] for non-Load3D nodes', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = {
constructor: { comfyClass: 'OtherNode' }
} as unknown as LGraphNode
expect(load3DExt.getNodeMenuItems(node)).toEqual([])
})
it('Comfy.Preview3D returns [] for non-Preview3D nodes', async () => {
const { preview3DExt } = await loadExtensionsFresh()
const node = {
constructor: { comfyClass: 'OtherNode' }
} as unknown as LGraphNode
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] when no load3d instance exists for the node', async () => {
const { preview3DExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue(null)
const node = {
constructor: { comfyClass: 'Preview3D' }
} as unknown as LGraphNode
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] for splat models', async () => {
const { preview3DExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
const node = {
constructor: { comfyClass: 'Preview3D' }
} as unknown as LGraphNode
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
})
it('returns export menu items for non-splat 3D nodes', async () => {
const { preview3DExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
const node = {
constructor: { comfyClass: 'Preview3D' }
} as unknown as LGraphNode
expect(preview3DExt.getNodeMenuItems(node)).toEqual([{ content: 'Export' }])
})
})

View File

@@ -6,17 +6,22 @@ import Load3DConfiguration, {
} from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
GizmoConfig,
ModelConfig
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const { settingsGetMock } = vi.hoisted(() => ({
settingsGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
useSettingStore: () => ({ get: settingsGetMock })
}))
vi.mock('@/scripts/api', () => ({
@@ -43,13 +48,22 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
}
}))
type WithPrivate = { loadModelConfig(): ModelConfig }
type WithPrivate = {
loadModelConfig(): ModelConfig
loadSceneConfig(): SceneConfig
loadCameraConfig(): CameraConfig
loadLightConfig(): LightConfig
}
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
const load3d = {} as Load3d
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
}
function stubSettings(values: Record<string, unknown>) {
settingsGetMock.mockImplementation((key: string) => values[key])
}
const defaultGizmo: GizmoConfig = {
enabled: false,
mode: 'translate',
@@ -58,6 +72,13 @@ const defaultGizmo: GizmoConfig = {
scale: { x: 1, y: 1, z: 1 }
}
const hdriDefaults = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
} as const
describe('Load3DConfiguration.loadModelConfig', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -342,3 +363,233 @@ describe('parseAnnotatedFilename', () => {
})
})
})
describe('Load3DConfiguration.loadSceneConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the persisted Scene Config when present, ignoring settings', () => {
const stored: SceneConfig = {
showGrid: false,
backgroundColor: '#123456',
backgroundImage: 'bg.png'
}
const properties = { 'Scene Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': 'aaaaaa'
})
expect(createConfig(properties).loadSceneConfig()).toEqual(stored)
expect(settingsGetMock).not.toHaveBeenCalled()
})
it('falls back to settings and prepends # to the background color', () => {
stubSettings({
'Comfy.Load3D.ShowGrid': false,
'Comfy.Load3D.BackgroundColor': 'abcdef'
})
expect(createConfig().loadSceneConfig()).toEqual({
showGrid: false,
backgroundColor: '#abcdef',
backgroundImage: ''
})
})
})
describe('Load3DConfiguration.loadCameraConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the persisted Camera Config when present', () => {
const stored: CameraConfig = {
cameraType: 'orthographic',
fov: 50
}
const properties = { 'Camera Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
expect(createConfig(properties).loadCameraConfig()).toEqual(stored)
expect(settingsGetMock).not.toHaveBeenCalled()
})
it('falls back to settings and a default fov of 35', () => {
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
expect(createConfig().loadCameraConfig()).toEqual({
cameraType: 'perspective',
fov: 35
})
})
})
describe('Load3DConfiguration.loadLightConfig', () => {
beforeEach(() => {
settingsGetMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('falls back to settings with default hdri when nothing is persisted', () => {
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig().loadLightConfig()).toEqual({
intensity: 4,
hdri: hdriDefaults
})
})
it('uses the persisted intensity over the setting when present', () => {
const stored: Partial<LightConfig> = { intensity: 7 }
const properties = { 'Light Config': stored } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 7,
hdri: hdriDefaults
})
})
it('falls back to the setting intensity when persisted intensity is missing', () => {
const properties = { 'Light Config': {} } as Dictionary<
NodeProperty | undefined
>
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 4,
hdri: hdriDefaults
})
})
it('merges persisted hdri partial over hdri defaults', () => {
const stored: Partial<LightConfig> = {
intensity: 2,
hdri: { hdriPath: 'env.hdr', enabled: true } as LightConfig['hdri']
}
const properties = { 'Light Config': stored } as Dictionary<
NodeProperty | undefined
>
expect(createConfig(properties).loadLightConfig()).toEqual({
intensity: 2,
hdri: {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 1
}
})
})
})
describe('Load3DConfiguration.configure forwards persisted + settings to load3d', () => {
let load3d: Load3d
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
settingsGetMock.mockReset()
load3d = makeLoad3dMock()
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('uses settings defaults when no Scene/Camera/Light Config is persisted', async () => {
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': '282828',
'Comfy.Load3D.CameraType': 'orthographic',
'Comfy.Load3D.LightIntensity': 6
})
const config = new Load3DConfiguration(load3d)
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(load3d.toggleGrid).toHaveBeenCalledWith(true)
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#282828')
expect(load3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(load3d.setFOV).toHaveBeenCalledWith(35)
expect(load3d.setLightIntensity).toHaveBeenCalledWith(6)
})
it('prefers persisted Scene/Camera/Light Config over settings', async () => {
const properties = {
'Scene Config': {
showGrid: false,
backgroundColor: '#101010',
backgroundImage: ''
},
'Camera Config': { cameraType: 'perspective', fov: 60 },
'Light Config': { intensity: 9 }
} as unknown as Dictionary<NodeProperty | undefined>
stubSettings({
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': '282828',
'Comfy.Load3D.CameraType': 'orthographic',
'Comfy.Load3D.LightIntensity': 1
})
const config = new Load3DConfiguration(load3d, properties)
config.configure({
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
loadFolder: 'output'
})
await flush()
expect(load3d.toggleGrid).toHaveBeenCalledWith(false)
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#101010')
expect(load3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(load3d.setFOV).toHaveBeenCalledWith(60)
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
})
})

View File

@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyExtension } from '@/types/comfy'
const { registerExtensionMock, waitForLoad3dMock, configureForSaveMeshMock } =
vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
configureForSaveMeshMock: vi.fn()
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({ getLoad3d: vi.fn() })
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock })
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configureForSaveMesh = configureForSaveMeshMock
}
}))
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
createExportMenuItems: vi.fn(() => [])
}))
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn(),
addWidget: vi.fn()
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn()
}))
type SaveMeshExtension = ComfyExtension & {
nodeCreated: (node: LGraphNode) => Promise<void>
}
async function loadSaveMeshExtensionFresh(): Promise<SaveMeshExtension> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/saveMesh')
return registerExtensionMock.mock.calls[0][0] as SaveMeshExtension
}
function makeNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
}> = {}
): LGraphNode {
const { comfyClass = 'SaveGLB', properties = {} } = overrides
return {
constructor: { comfyClass },
size: [400, 550],
setSize: vi.fn(),
widgets: [{ name: 'image', value: '' }],
properties
} as unknown as LGraphNode
}
describe('saveMesh', () => {
beforeEach(() => {
vi.clearAllMocks()
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
cb({
whenLoadIdle: () => Promise.resolve(),
captureThumbnail: vi.fn()
})
})
})
it('registers a single Comfy.SaveGLB extension on import', async () => {
const ext = await loadSaveMeshExtensionFresh()
expect(registerExtensionMock).toHaveBeenCalledOnce()
expect(ext.name).toBe('Comfy.SaveGLB')
expect(typeof ext.nodeCreated).toBe('function')
})
it('skips nodes whose comfyClass is not SaveGLB', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({ comfyClass: 'OtherNode' })
await ext.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('does not load a model on creation when no Last Time Model File is persisted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores the persisted model on creation using the persisted folder', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({
properties: {
'Last Time Model File': 'sub/model.glb',
'Last Time Model Folder': 'output'
}
})
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/model.glb',
{ silentOnNotFound: true }
)
expect(node.widgets?.find((w) => w.name === 'image')?.value).toBe(
'sub/model.glb'
)
})
it('defaults the load folder to output when only the file path is persisted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode({
properties: { 'Last Time Model File': 'model.glb' }
})
await ext.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'model.glb',
{ silentOnNotFound: true }
)
})
it('persists Last Time Model File and Folder after onExecuted', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
})
expect(node.properties['Last Time Model File']).toBe('sub/mesh.glb')
expect(node.properties['Last Time Model Folder']).toBe('output')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/mesh.glb',
{ silentOnNotFound: true }
)
})
it('does not persist anything when onExecuted has no 3d output', async () => {
const ext = await loadSaveMeshExtensionFresh()
const node = makeNode()
await ext.nodeCreated(node)
node.onExecuted!({})
expect(node.properties['Last Time Model File']).toBeUndefined()
expect(node.properties['Last Time Model Folder']).toBeUndefined()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('uses the persisted state from a prior run when the node is recreated', async () => {
const ext = await loadSaveMeshExtensionFresh()
const firstNode = makeNode()
await ext.nodeCreated(firstNode)
firstNode.onExecuted!({
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
})
configureForSaveMeshMock.mockClear()
const recreated = makeNode({ properties: { ...firstNode.properties } })
await ext.nodeCreated(recreated)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/mesh.glb',
{ silentOnNotFound: true }
)
})
})

View File

@@ -81,6 +81,32 @@ useExtensionService().registerExtension({
await nextTick()
useLoad3d(node).waitForLoad3d((load3d) => {
if (!load3d) return
const modelWidget = node.widgets?.find((w) => w.name === 'image')
if (!modelWidget) return
const lastTimeModelFile = node.properties['Last Time Model File'] as
| string
| undefined
const lastTimeModelFolder =
(node.properties['Last Time Model Folder'] as
| 'input'
| 'output'
| undefined) ?? 'output'
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(lastTimeModelFolder, lastTimeModelFile, {
silentOnNotFound: true
})
}
})
const onExecuted = node.onExecuted
node.onExecuted = function (output: SaveMeshOutput) {
@@ -103,6 +129,9 @@ useExtensionService().registerExtension({
const loadFolder = fileInfo.type as 'input' | 'output'
node.properties['Last Time Model File'] = filePath
node.properties['Last Time Model Folder'] = loadFolder
config.configureForSaveMesh(loadFolder, filePath, {
silentOnNotFound: true
})

View File

@@ -1,248 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
const { mockAddAlert, mockApiURL, mockFetchApi, mockRegisterExtension } =
vi.hoisted(() => ({
mockAddAlert: vi.fn(),
mockApiURL: vi.fn((url: string) => `api:${url}`),
mockFetchApi: vi.fn(),
mockRegisterExtension: vi.fn()
}))
let capturedDragDrop: ((files: File[]) => Promise<File[] | never[]>) | undefined
let capturedFileSelect:
| ((files: File[]) => Promise<File[] | never[]>)
| undefined
let capturedPaste: ((files: File[]) => Promise<File[] | never[]>) | undefined
type AudioUploadWidget = (node: LGraphNode, inputName: string) => unknown
vi.mock('extendable-media-recorder', () => ({
MediaRecorder: class MockMediaRecorder {}
}))
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
options: { onDrop: typeof capturedDragDrop }
) => {
capturedDragDrop = options.onDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: (
_node: LGraphNode,
options: { onSelect: typeof capturedFileSelect }
) => {
capturedFileSelect = options.onSelect
return { openFileSelection: vi.fn() }
}
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: (
_node: LGraphNode,
options: { onPaste: typeof capturedPaste }
) => {
capturedPaste = options.onPaste
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: mockAddAlert })
}))
vi.mock('@/renderer/extensions/vueNodes/widgets/utils/audioUtils', () => ({
getResourceURL: (subfolder = '', filename = '', type = 'input') =>
`/view?filename=${filename}&subfolder=${subfolder}&type=${type}`,
splitFilePath: (path: string) => ['', path, 'input']
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: mockApiURL,
fetchApi: mockFetchApi
}
}))
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: mockRegisterExtension,
rootGraph: { id: 'root' }
}
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: vi.fn()
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn()
}))
vi.mock('@/services/audioService', () => ({
useAudioService: () => ({})
}))
function createFile(name = 'clip.mp3'): File {
return new File(['audio'], name, { type: 'audio/mpeg' })
}
function successResponse(name: string, subfolder?: string) {
return {
status: 200,
json: () => Promise.resolve({ name, subfolder })
}
}
function failResponse(status = 500) {
return {
status,
statusText: 'Server Error'
}
}
function createAudioNode() {
const audioWidget = {
name: 'audio',
value: 'previous.mp3',
options: { values: ['previous.mp3'] },
callback: vi.fn()
}
const audioUIWidget = {
name: 'audioUI',
element: document.createElement('audio'),
value: '',
callback: vi.fn()
}
const uploadWidget = { label: '', serialize: true, canvasOnly: false }
const node = fromAny<LGraphNode, unknown>({
widgets: [audioWidget, audioUIWidget],
isUploading: false,
graph: { setDirtyCanvas: vi.fn() },
addWidget: vi.fn(() => uploadWidget),
onWidgetChanged: vi.fn()
})
return { audioUIWidget, audioWidget, node, uploadWidget }
}
async function loadAudioUploadWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.UploadAudio')
if (!extension)
throw new Error('Comfy.UploadAudio extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUploadWidget>).AUDIOUPLOAD
}
describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedDragDrop = undefined
capturedFileSelect = undefined
capturedPaste = undefined
})
it('sets isUploading while upload is in progress and clears it after success', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
let resolveUpload: (response: ReturnType<typeof successResponse>) => void
mockFetchApi.mockReturnValueOnce(
new Promise((resolve) => {
resolveUpload = resolve
})
)
const upload = capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(true)
expect(audioWidget.value).toBe('clip.mp3')
resolveUpload!(successResponse('uploaded.mp3', 'pasted'))
await upload
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('pasted/uploaded.mp3')
expect(audioWidget.options.values).toContain('pasted/uploaded.mp3')
expect(node.onWidgetChanged).toHaveBeenCalledWith(
'audio',
'pasted/uploaded.mp3',
'clip.mp3',
audioWidget
)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rejects concurrent audio uploads without starting another request', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
node.isUploading = true
const result = await capturedDragDrop!([createFile()])
expect(result).toEqual([])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('rolls back the widget value and clears isUploading when upload fails', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
mockFetchApi.mockResolvedValueOnce(failResponse())
await capturedPaste!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith('500 - Server Error')
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('rolls back the widget value and clears isUploading when upload throws synchronously', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { audioWidget, node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const error = new Error('Upload failed before request promise')
mockFetchApi.mockImplementationOnce(() => {
throw error
})
await capturedDragDrop!([createFile()])
expect(node.isUploading).toBe(false)
expect(audioWidget.value).toBe('previous.mp3')
expect(mockAddAlert).toHaveBeenCalledWith(error)
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('returns early when no files are provided', async () => {
const AUDIOUPLOAD = await loadAudioUploadWidget()
const { node } = createAudioNode()
AUDIOUPLOAD(node, 'upload')
const result = await capturedFileSelect!([])
expect(result).toEqual([])
expect(node.isUploading).toBe(false)
expect(mockFetchApi).not.toHaveBeenCalled()
})
})

View File

@@ -38,7 +38,6 @@ function updateUIWidget(
}
async function uploadFile(
node: LGraphNode,
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
@@ -68,7 +67,6 @@ async function uploadFile(
}
if (updateNode) {
const oldValue = audioWidget.value
updateUIWidget(
audioUIWidget,
api.apiURL(getResourceURL(...splitFilePath(path)))
@@ -77,7 +75,6 @@ async function uploadFile(
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
node.onWidgetChanged?.(audioWidget.name, path, oldValue, audioWidget)
}
return true
} else {
@@ -237,19 +234,10 @@ app.registerExtension({
}
const handleUpload = async (files: File[]) => {
if (!files?.length) return files
if (node.isUploading) {
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
return []
}
node.isUploading = true
const previousValue = audioWidget.value
audioWidget.value = files[0].name
try {
if (files?.length) {
const previousValue = audioWidget.value
audioWidget.value = files[0].name
const success = await uploadFile(
node,
audioWidget,
audioUIWidget,
files[0],
@@ -258,9 +246,6 @@ app.registerExtension({
if (!success) {
audioWidget.value = previousValue
}
} finally {
node.isUploading = false
node.graph?.setDirtyCanvas(true)
}
return files
}

View File

@@ -1,21 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18nModule from './i18n'
let i18n: typeof I18nModule.i18n
let loadLocale: typeof I18nModule.loadLocale
let mergeCustomNodesI18n: typeof I18nModule.mergeCustomNodesI18n
let resolveSupportedLocale: typeof I18nModule.resolveSupportedLocale
let setActiveLocale: typeof I18nModule.setActiveLocale
async function importI18nModule() {
const i18nModule = await import('./i18n')
i18n = i18nModule.i18n
loadLocale = i18nModule.loadLocale
mergeCustomNodesI18n = i18nModule.mergeCustomNodesI18n
resolveSupportedLocale = i18nModule.resolveSupportedLocale
setActiveLocale = i18nModule.setActiveLocale
}
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
@@ -40,7 +24,6 @@ vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
describe('i18n', () => {
beforeEach(async () => {
vi.resetModules()
await importI18nModule()
})
describe('mergeCustomNodesI18n', () => {
@@ -63,6 +46,8 @@ describe('i18n', () => {
})
it('should store data for not-yet-loaded locales', async () => {
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
// Chinese is not pre-loaded, data should be stored but not merged yet
mergeCustomNodesI18n({
zh: {
@@ -163,7 +148,7 @@ describe('i18n', () => {
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
// Use fresh module instance to ensure clean state
vi.resetModules()
await importI18nModule()
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
mergeCustomNodesI18n({
zh: { plugin1: { name: '插件1' } }
@@ -190,88 +175,26 @@ describe('i18n', () => {
it('should not reload already loaded locale', async () => {
await loadLocale('zh')
await loadLocale('zh')
// Should complete without error (second call returns early)
})
it('should load shipped BCP-47 variants', async () => {
await loadLocale('zh-TW')
expect(i18n.global.getLocaleMessage('zh-TW')).toEqual(
expect.objectContaining({
commands: expect.any(Object),
nodeDefs: expect.any(Object),
settings: expect.any(Object)
})
it('should warn for unsupported locale', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await loadLocale('unsupported-locale')
expect(consoleSpy).toHaveBeenCalledWith(
'Locale "unsupported-locale" is not supported'
)
consoleSpy.mockRestore()
})
it('should handle concurrent load requests for same locale', async () => {
// Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
await Promise.all(promises)
})
})
describe('setActiveLocale', () => {
it('clamps unsupported input to en', async () => {
expect(await setActiveLocale('de')).toBe('en')
expect(i18n.global.locale.value).toBe('en')
})
it('resolves shipped variants and sets the active locale', async () => {
expect(await setActiveLocale('pt-BR')).toBe('pt-BR')
expect(i18n.global.locale.value).toBe('pt-BR')
// pt is not shipped — pt-BR must not be promoted as a base match
expect(await setActiveLocale('pt')).toBe('en')
})
it('honors prioritized navigator.languages', async () => {
// First preference unsupported, second shipped — should land on French.
expect(await setActiveLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
})
})
describe('resolveSupportedLocale', () => {
it('returns the canonical tag when the input is shipped', () => {
expect(resolveSupportedLocale('en')).toBe('en')
expect(resolveSupportedLocale('ja')).toBe('ja')
expect(resolveSupportedLocale('zh-TW')).toBe('zh-TW')
expect(resolveSupportedLocale('pt-BR')).toBe('pt-BR')
})
it('matches case-insensitively per BCP-47 and returns canonical casing', () => {
// Older browsers / OS configs may emit lowercase region tags.
expect(resolveSupportedLocale('pt-br')).toBe('pt-BR')
expect(resolveSupportedLocale('PT-BR')).toBe('pt-BR')
expect(resolveSupportedLocale('zh-tw')).toBe('zh-TW')
expect(resolveSupportedLocale('ZH-TW')).toBe('zh-TW')
expect(resolveSupportedLocale('EN')).toBe('en')
})
it('falls back to the base tag when the full tag is unshipped', () => {
// de-DE → de (unshipped) → en
expect(resolveSupportedLocale('de-DE')).toBe('en')
// fr-CA → fr (shipped) → fr
expect(resolveSupportedLocale('fr-CA')).toBe('fr')
// ko-KR → ko (shipped) → ko
expect(resolveSupportedLocale('ko-KR')).toBe('ko')
// zh-CN → zh (shipped) → zh (Simplified is the base)
expect(resolveSupportedLocale('zh-CN')).toBe('zh')
})
it('falls back to en for unsupported and missing inputs', () => {
expect(resolveSupportedLocale('de')).toBe('en')
expect(resolveSupportedLocale('it')).toBe('en')
expect(resolveSupportedLocale('nl')).toBe('en')
expect(resolveSupportedLocale('xx-YY')).toBe('en')
expect(resolveSupportedLocale('')).toBe('en')
expect(resolveSupportedLocale(undefined)).toBe('en')
expect(resolveSupportedLocale(null)).toBe('en')
})
it('walks a prioritized array per RFC 4647 lookup order', () => {
// First shipped match wins (de unshipped → fr shipped → fr).
expect(resolveSupportedLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
// Empty / all-unshipped arrays fall back to en.
expect(resolveSupportedLocale([])).toBe('en')
expect(resolveSupportedLocale(['de', 'it'])).toBe('en')
})
})
})

View File

@@ -1,11 +1,7 @@
import { createI18n } from 'vue-i18n'
import {
getDefaultLocale,
localeDefinitions,
resolveSupportedLocale
} from '@/locales/localeConfig'
import type { SupportedLocale } from '@/locales/localeConfig'
// ESLint cannot statically resolve dynamic imports with relative paths in template strings,
// but these are valid ES module imports that Vite processes correctly at build time.
// Import only English locale eagerly as the default/fallback
import enCommands from './locales/en/commands.json' with { type: 'json' }
@@ -13,8 +9,6 @@ import en from './locales/en/main.json' with { type: 'json' }
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from './locales/en/settings.json' with { type: 'json' }
export { resolveSupportedLocale }
function buildLocale<
M extends Record<string, unknown>,
N extends Record<string, unknown>,
@@ -29,6 +23,75 @@ function buildLocale<
} as M & { nodeDefs: N; commands: C; settings: S }
}
// Locale loader map - dynamically import locales only when needed
const localeLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/main.json'),
es: () => import('./locales/es/main.json'),
fa: () => import('./locales/fa/main.json'),
fr: () => import('./locales/fr/main.json'),
ja: () => import('./locales/ja/main.json'),
ko: () => import('./locales/ko/main.json'),
ru: () => import('./locales/ru/main.json'),
tr: () => import('./locales/tr/main.json'),
zh: () => import('./locales/zh/main.json'),
'zh-TW': () => import('./locales/zh-TW/main.json'),
'pt-BR': () => import('./locales/pt-BR/main.json')
}
const nodeDefsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/nodeDefs.json'),
es: () => import('./locales/es/nodeDefs.json'),
fa: () => import('./locales/fa/nodeDefs.json'),
fr: () => import('./locales/fr/nodeDefs.json'),
ja: () => import('./locales/ja/nodeDefs.json'),
ko: () => import('./locales/ko/nodeDefs.json'),
ru: () => import('./locales/ru/nodeDefs.json'),
tr: () => import('./locales/tr/nodeDefs.json'),
zh: () => import('./locales/zh/nodeDefs.json'),
'zh-TW': () => import('./locales/zh-TW/nodeDefs.json'),
'pt-BR': () => import('./locales/pt-BR/nodeDefs.json')
}
const commandsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/commands.json'),
es: () => import('./locales/es/commands.json'),
fa: () => import('./locales/fa/commands.json'),
fr: () => import('./locales/fr/commands.json'),
ja: () => import('./locales/ja/commands.json'),
ko: () => import('./locales/ko/commands.json'),
ru: () => import('./locales/ru/commands.json'),
tr: () => import('./locales/tr/commands.json'),
zh: () => import('./locales/zh/commands.json'),
'zh-TW': () => import('./locales/zh-TW/commands.json'),
'pt-BR': () => import('./locales/pt-BR/commands.json')
}
const settingsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('./locales/ar/settings.json'),
es: () => import('./locales/es/settings.json'),
fa: () => import('./locales/fa/settings.json'),
fr: () => import('./locales/fr/settings.json'),
ja: () => import('./locales/ja/settings.json'),
ko: () => import('./locales/ko/settings.json'),
ru: () => import('./locales/ru/settings.json'),
tr: () => import('./locales/tr/settings.json'),
zh: () => import('./locales/zh/settings.json'),
'zh-TW': () => import('./locales/zh-TW/settings.json'),
'pt-BR': () => import('./locales/pt-BR/settings.json')
}
// Track which locales have been loaded
const loadedLocales = new Set<string>(['en'])
@@ -39,33 +102,37 @@ const loadingLocales = new Map<string, Promise<void>>()
const customNodesI18nData: Record<string, unknown> = {}
/**
* Dynamically load a shipped locale's bundles (nodeDefs, commands, settings).
* Callers must pre-resolve untrusted input via `resolveSupportedLocale` or
* `setActiveLocale`, which is the boundary helper for arbitrary input.
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
export async function loadLocale(locale: SupportedLocale): Promise<void> {
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) {
return
}
// If already loading, return the existing promise to prevent duplicate loads
const existingLoad = loadingLocales.get(locale)
if (existingLoad) {
await existingLoad
return
}
const loaders = localeDefinitions[locale].loaders
if (!loaders) {
return existingLoad
}
const loader = localeLoaders[locale]
const nodeDefsLoader = nodeDefsLoaders[locale]
const commandsLoader = commandsLoaders[locale]
const settingsLoader = settingsLoaders[locale]
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
console.warn(`Locale "${locale}" is not supported`)
return
}
// Create and track the loading promise
const loadPromise = (async () => {
try {
const [main, nodes, commands, settings] = await Promise.all([
loaders.main(),
loaders.nodeDefs(),
loaders.commands(),
loaders.settings()
loader(),
nodeDefsLoader(),
commandsLoader(),
settingsLoader()
])
const messages = buildLocale(
@@ -85,33 +152,13 @@ export async function loadLocale(locale: SupportedLocale): Promise<void> {
console.error(`Failed to load locale "${locale}":`, error)
throw error
} finally {
// Clean up the loading promise once complete
loadingLocales.delete(locale)
}
})()
loadingLocales.set(locale, loadPromise)
await loadPromise
}
/**
* Boundary helper for arbitrary locale input (settings, browser preferences):
* resolves to a shipped tag, loads it, and updates the active locale.
*
* Returns the resolved tag so callers can detect a clamp (e.g. a stale stored
* `Comfy.Locale` from an older build) and self-heal persisted state.
*/
export async function setActiveLocale(
input: string | readonly string[] | null | undefined
): Promise<SupportedLocale> {
const resolved = resolveSupportedLocale(input)
if (typeof input === 'string' && input && input !== resolved) {
// Single warn — gated on a real clamp event, never per missing key — so
// stale stored locales surface in logs without re-introducing #1867's spam.
console.warn(`Locale "${input}" not shipped; using "${resolved}"`)
}
await loadLocale(resolved)
i18n.global.locale.value = resolved
return resolved
return loadPromise
}
/**
@@ -132,18 +179,18 @@ export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
}
}
// Only include English in the initial bundle; other locales lazy-load.
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
type LocaleMessages = typeof enMessages
const messages: Partial<Record<SupportedLocale, LocaleMessages>> = {
en: enMessages
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
escapeParameter: true,
messages,

View File

@@ -3,7 +3,6 @@ import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -3295,15 +3294,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (result != null) this.dirty_canvas = result
}
}
const firstLink: RenderLink | undefined = linkConnector.renderLinks.at(0)
const isSubgraphIOLink =
linkConnector.isConnecting && firstLink?.isIoNodeLink
// get node over
const node =
LiteGraph.vueNodesMode && !isSubgraphIOLink
? null
: graph.getNodeOnPos(x, y, this.visible_nodes)
const node = LiteGraph.vueNodesMode
? null
: graph.getNodeOnPos(x, y, this.visible_nodes)
const dragRect = this.dragging_rectangle
if (dragRect) {
@@ -3394,6 +3389,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Check if link is over anything it could connect to - record position of valid target for snap / highlight
if (linkConnector.isConnecting) {
const firstLink = linkConnector.renderLinks.at(0)
// Default: nothing highlighted
let highlightPos: Point | undefined
let highlightInput: INodeInputSlot | undefined
@@ -3444,7 +3441,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
highlightInput = node.inputs[inputId]
}
if (highlightInput && !LiteGraph.vueNodesMode) {
if (highlightInput) {
const widget = node.getWidgetFromSlot(highlightInput)
if (widget) linkConnector.overWidget = widget
}

View File

@@ -43,8 +43,6 @@ export interface RenderLink {
/** The reroute that the link is being connected from. */
readonly fromReroute?: Reroute
readonly isIoNodeLink?: boolean
/**
* Capability checks used for hit-testing and validation during drag.
* Implementations should return `false` when a connection is not possible

View File

@@ -24,7 +24,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
readonly fromPos: Point
fromDirection: LinkDirection = LinkDirection.RIGHT
readonly existingLink?: LLink
readonly isIoNodeLink = true
constructor(
readonly network: LinkNetwork,

View File

@@ -23,7 +23,6 @@ export class ToOutputFromIoNodeLink implements RenderLink {
readonly fromPos: Point
readonly fromSlotIndex: SlotIndex
fromDirection: LinkDirection = LinkDirection.LEFT
readonly isIoNodeLink = true
constructor(
readonly network: LinkNetwork,

View File

@@ -136,13 +136,6 @@ export class SubgraphInput extends SubgraphSlot {
}
subgraph.incrementVersion()
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
subgraph.afterChange()
@@ -246,8 +239,11 @@ export class SubgraphInput extends SubgraphSlot {
override isValidTarget(
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
): boolean {
if (isNodeSlot(fromSlot) && 'link' in fromSlot) {
return LiteGraph.isValidConnection(this.type, fromSlot.type)
if (isNodeSlot(fromSlot)) {
return (
'link' in fromSlot &&
LiteGraph.isValidConnection(this.type, fromSlot.type)
)
}
if (isSubgraphOutput(fromSlot)) {

View File

@@ -226,13 +226,6 @@ export class SubgraphInputNode
link,
subgraphInput
)
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: slotIndex,
connected: false,
linkId: link.id
})
}
}

View File

@@ -140,8 +140,11 @@ export class SubgraphOutput extends SubgraphSlot {
override isValidTarget(
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
): boolean {
if (isNodeSlot(fromSlot) && 'links' in fromSlot) {
return LiteGraph.isValidConnection(fromSlot.type, this.type)
if (isNodeSlot(fromSlot)) {
return (
'links' in fromSlot &&
LiteGraph.isValidConnection(fromSlot.type, this.type)
)
}
if (isSubgraphInput(fromSlot)) {

View File

@@ -35,13 +35,47 @@ module.exports = defineConfig({
})
```
#### 1.2 Update `src/locales/localeConfig.ts`
#### 1.2 Update `src/platform/settings/constants/coreSettings.ts`
Add your language to the shared runtime locale definition. This feeds the
settings dropdown, supported-locale resolution, and lazy locale loading:
Add your language to the dropdown options:
```typescript
'zh-TW': { text: '繁體中文', loaders: loadersFor('zh-TW') }
{
id: 'Comfy.Locale',
name: 'Language',
type: 'combo',
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'zh-TW', text: '繁體中文 (台灣)' }, // Add your language here
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
```
#### 1.3 Update `src/i18n.ts`
Add imports for your new language files:
```typescript
// Add these imports (replace zh-TW with your language code)
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
// Add to the messages object
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings) // Add this line
// ... other languages
}
```
### Step 2: Generate Translation Files
@@ -134,7 +168,7 @@ Each language has 4 translation files:
### Issue: Language not appearing in dropdown
**Solution**: Check that the language code in `src/locales/localeConfig.ts` matches your other files exactly
**Solution**: Check that the language code in `coreSettings.ts` matches your other files exactly
### Issue: Rate limits during local translation

View File

@@ -237,6 +237,7 @@
},
"login": {
"andText": "و",
"backToGithubLogin": "سجّل باستخدام Github بدلاً من ذلك",
"backToLogin": "العودة إلى تسجيل الدخول",
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
"confirmPasswordLabel": "تأكيد كلمة المرور",
@@ -835,6 +836,7 @@
"NOISE": "ضجيج",
"OPENAI_CHAT_CONFIG": "إعدادات محادثة أوبن إيه آي",
"OPENAI_INPUT_FILES": "ملفات إدخال أوبن إيه آي",
"OPTICAL_FLOW": "التدفق البصري",
"PHOTOMAKER": "صانع الصور",
"PIXVERSE_TEMPLATE": "قالب PixVerse",
"POSE_KEYPOINT": "نقطة مفتاحية للوضعية",

View File

@@ -11917,6 +11917,20 @@
}
}
},
"OpticalFlowLoader": {
"display_name": "تحميل نموذج التدفق البصري",
"inputs": {
"model_name": {
"name": "model_name",
"tooltip": "نموذج التدفق البصري المراد تحميله. يجب وضع الملفات في مجلد 'optical_flow'. حالياً، فقط raft_large.pth من torchvision مدعوم."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OptimalStepsScheduler": {
"display_name": "مجدول الخطوات الأمثل",
"inputs": {
@@ -17811,6 +17825,127 @@
}
}
},
"VOIDInpaintConditioning": {
"display_name": "VOIDInpaintConditioning",
"inputs": {
"batch_size": {
"name": "batch_size"
},
"height": {
"name": "height"
},
"length": {
"name": "length",
"tooltip": "عدد إطارات البكسل للمعالجة. بالنسبة لـ CogVideoX-Fun-V1.5 (patch_size_t=2)، يجب أن يكون latent_t عدداً زوجياً — الأطوال التي تنتج latent_t فردياً يتم تقريبها للأسفل (مثال: 49 → 45)."
},
"negative": {
"name": "negative"
},
"positive": {
"name": "positive"
},
"quadmask": {
"name": "quadmask",
"tooltip": "قناع رباعي معالج مسبقاً من VOIDQuadmaskPreprocess [T, H, W]"
},
"vae": {
"name": "vae"
},
"video": {
"name": "video",
"tooltip": "إطارات الفيديو المصدر [T, H, W, 3]"
},
"width": {
"name": "width"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": null
}
}
},
"VOIDQuadmaskPreprocess": {
"display_name": "VOIDQuadmaskPreprocess",
"inputs": {
"dilate_width": {
"name": "dilate_width",
"tooltip": "نصف قطر التوسيع لمنطقة القناع الأساسية (٠ = بدون توسيع)"
},
"mask": {
"name": "mask"
}
},
"outputs": {
"0": {
"name": "quadmask",
"tooltip": null
}
}
},
"VOIDSampler": {
"display_name": "VOIDSampler",
"outputs": {
"0": {
"tooltip": null
}
}
},
"VOIDWarpedNoise": {
"display_name": "VOIDWarpedNoise",
"inputs": {
"batch_size": {
"name": "batch_size"
},
"height": {
"name": "height"
},
"length": {
"name": "length",
"tooltip": "عدد إطارات البكسل. يتم التقريب للأسفل لجعل latent_t عدداً زوجياً (متطلب patch_size_t=2)، مثال: 49 → 45."
},
"optical_flow": {
"name": "optical_flow",
"tooltip": "نموذج التدفق البصري من OpticalFlowLoader (RAFT-large)."
},
"video": {
"name": "video",
"tooltip": "إطارات الفيديو الناتجة من المرحلة الأولى [T, H, W, 3]"
},
"width": {
"name": "width"
}
},
"outputs": {
"0": {
"name": "warped_noise",
"tooltip": null
}
}
},
"VOIDWarpedNoiseSource": {
"display_name": "VOIDWarpedNoiseSource",
"inputs": {
"warped_noise": {
"name": "warped_noise",
"tooltip": "الضجيج المشوه (latent) من VOIDWarpedNoise"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VPScheduler": {
"display_name": "مجدول VP",
"inputs": {

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