Compare commits

..

16 Commits

Author SHA1 Message Date
coderabbitai[bot]
81965d4c5f CodeRabbit Generated Unit Tests: Add unit tests for PR changes 2026-05-07 18:19:20 +00:00
pythongosssss
219a574eed test: add failing test for deleting inner nodes with promoted widgets (#12058)
## Summary

Adds a failing test for a current bug with subgraphs where deleting an
inner node where the widget is promoted does not remove the outer widget

## Changes

- **What**: 
- add failing test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12058-test-add-failing-test-for-deleting-inner-nodes-with-promoted-widgets-3596d73d3650811f9629c0ed3f4bc3c8)
by [Unito](https://www.unito.io)
2026-05-07 18:01:56 +00:00
Yourz
fef2cab31e fix(website): prevent video glitch when c-projection.webp loads on /customers (#12060)
*PR Created by the Glary-Bot Agent*

---

## Summary

Fix the video position glitch on `/customers` caused by
`c-projection.webp` loading.

## Root cause

In `HeroSection.vue` the hero `<img>` for `c-projection.webp` had no
`width`/`height` attributes, so the browser reserved no space for it.
When the image finished loading, the layout shifted ~192px, pushing the
`VideoPlayer` below it. `useHeroAnimation` registers a GSAP
`ScrollTrigger` parallax against the video in `onMounted` (before the
image is loaded), so the cached scroll geometry then went stale and the
video visibly glitched.

## Fix

- Add explicit `width="1568"` / `height="1763"` to the `<img>` (the
image's native size) so the browser reserves the correct aspect-ratio'd
space upfront.
- Add `h-auto` so the height attribute doesn't override the responsive
layout.
- Refresh `ScrollTrigger` on the image's `@load` (with `refresh(true)`
so measurements happen after layout has settled) as a defensive measure
for any sub-pixel adjustments.

## Test coverage

Added `apps/website/e2e/customers.spec.ts` with a regression guard that:
1. Asserts the hero `<img>` declares numeric `width`/`height`
attributes.
2. Asserts the unloaded image still reserves vertical space (>100px),
which is the exact property that prevents the video from jumping when
the image finishes loading.

Verified the test fails when the `width`/`height` attributes are removed
and passes with the fix applied.

## Verification

- `pnpm typecheck` clean.
- `pnpm test:unit` (website app) — 30/30 pass.
- `pnpm test:e2e customers.spec.ts --project=desktop` — passes.
- ESLint + oxfmt clean on changed files.
- Manual Playwright verification confirmed the video bounding rect stays
at `top=785, height=628` through the full image load lifecycle.
Reverting the fix in DevTools and re-loading the image reproduces the
original ~192px shift.

## Out of scope

`apps/website/src/components/contact/FormSection.vue` uses the same
`c-projection.webp` pattern (also without `width`/`height`). It runs
`useHeroAnimation` with `parallax: false`, so the symptom is much
smaller — leaving as a follow-up to keep this PR minimal.

- Fixes
[FE-607](https://linear.app/comfyorg/issue/FE-607/bug-video-on-customers-shifts-position-when-c-projectionwebp-finishes)

## Screenshots

![/customers page rendered with the fix applied — video stays in correct
position below the
hero](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0ecf73b69b644739e2bdcce0e77bbe7eb44562f7643b82addc8903d68d25fef3/pr-images/1778167649187-841bf1fa-f3a7-4104-9dab-a024e62dcf4d.jpg)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12060-fix-website-prevent-video-glitch-when-c-projection-webp-loads-on-customers-3596d73d365081ebbcb8db25aaa5c451)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 15:38:39 +00:00
pythongosssss
20ee262f78 fix: prevent enter subgraph/toggle advanced when nodes were dragged (#12051)
## Summary

In Vue nodes mode, if you drag a node by the footer button (e.g. enter
subgraph) after you finish dragging, it then unexpectedly still enters
the subgraph, same applies to advanced widgets.

## Changes

- **What**: 
- store `isDraggingVueNodes` before click event and only emit if false

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12051-fix-prevent-enter-subgraph-toggle-advanced-when-nodes-were-dragged-3596d73d365081929173d8e9371b1332)
by [Unito](https://www.unito.io)
2026-05-07 14:20:09 +00:00
pythongosssss
6a8c453659 test: add missing emitModelReady to mock object (#12056)
## Summary

Unblock CI due to missing function in mock
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
TypeError: this.load3d.emitModelReady is not a function
 ❯ src/extensions/core/load3d/Load3DConfiguration.ts:313:19
    311|       }
    312| 
    313|       this.load3d.emitModelReady()
       |                   ^
    314|     }
    315|   }

This error originated in "src/extensions/core/load3d/Load3DConfiguration.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "prefers persisted Scene/Camera/Light Config over settings". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  773 passed (773)
      Tests  10417 passed | 8 skipped (10425)
     Errors  2 errors
   Start at  12:06:06
   Duration  446.79s (transform 32.89s, setup 113.26s, import 589.18s, tests 109.19s, environment 228.45s)
```

## Changes

- **What**: 
- add `emitModelReady` fn to mock

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12056-test-add-missing-emitModelReady-to-mock-object-3596d73d36508130a087f5c7713e932f)
by [Unito](https://www.unito.io)
2026-05-07 14:04:21 +00: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
64 changed files with 3764 additions and 271 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

@@ -0,0 +1,33 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

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

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

@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -22,6 +23,10 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -37,7 +42,10 @@ useHeroAnimation({
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-md lg:max-w-none"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
/>
</div>

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

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { t, hasKey, translationKeys } from './translations'
describe('translations', () => {
describe('pricing.plan.creator keys', () => {
it('feature1 key exists with correct English text', () => {
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
expect(t('pricing.plan.creator.feature1')).toBe('Import your own LoRAs')
})
it('feature1 key returns correct zh-CN translation', () => {
expect(t('pricing.plan.creator.feature1', 'zh-CN')).toBe(
'导入你自己的 LoRA'
)
})
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
})
it('translationKeys array does not include removed creator feature2', () => {
expect(translationKeys).not.toContain('pricing.plan.creator.feature2')
})
})
describe('pricing.plan.pro keys', () => {
it('feature1 key exists with correct English text', () => {
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
expect(t('pricing.plan.pro.feature1')).toBe(
'Longer workflow runtime (up to 1 hour)'
)
})
it('feature1 key returns correct zh-CN translation', () => {
expect(t('pricing.plan.pro.feature1', 'zh-CN')).toBe(
'更长工作流运行时长(最长 1 小时)'
)
})
it('feature2 key does not exist (removed concurrent API jobs entry)', () => {
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
})
it('translationKeys array does not include removed pro feature2', () => {
expect(translationKeys).not.toContain('pricing.plan.pro.feature2')
})
})
describe('t() function defaults', () => {
it('defaults locale to en when not provided', () => {
expect(t('pricing.plan.creator.feature1')).toBe(
t('pricing.plan.creator.feature1', 'en')
)
})
it('falls back to en when locale translation is missing', () => {
// All keys in this file have both en and zh-CN, but the fallback
// behaviour is part of the contract: t() should return en if the
// locale value is nullish.
const englishValue = t('pricing.plan.pro.feature1', 'en')
expect(englishValue).toBe('Longer workflow runtime (up to 1 hour)')
})
})
describe('hasKey()', () => {
it('returns true for known keys', () => {
expect(hasKey('hero.title')).toBe(true)
expect(hasKey('pricing.plan.creator.feature1')).toBe(true)
expect(hasKey('pricing.plan.pro.feature1')).toBe(true)
})
it('returns false for completely unknown keys', () => {
expect(hasKey('nonexistent.key')).toBe(false)
expect(hasKey('')).toBe(false)
})
it('returns false for the two removed concurrent-API-jobs keys', () => {
// Regression guard: these keys carried "3 concurrent API jobs" and
// "5 concurrent API jobs" copy that was intentionally removed.
expect(hasKey('pricing.plan.creator.feature2')).toBe(false)
expect(hasKey('pricing.plan.pro.feature2')).toBe(false)
})
})
})

View File

@@ -190,6 +190,9 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -500,6 +503,7 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

@@ -217,13 +217,20 @@ 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 locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
const editButton = this.getSubgraphEnterButton(nodeId)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.

View File

@@ -20,6 +20,7 @@ export class ContextMenu {
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.waitForHidden()
}
/**

View File

@@ -215,11 +215,12 @@ export class AssetHelper {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags)
let filtered = this.getFilteredAssets(includeTags, excludeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
@@ -296,15 +297,29 @@ export class AssetHelper {
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(tags: string[]): Asset[] {
private getFilteredAssets(
includeTags: string[],
excludeTags: string[]
): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
}
function parseAssetTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]

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

@@ -362,6 +362,9 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
if (this.comfyPage.isVueNodes) {
await this.comfyPage.vueNodes.waitForNodes()
}
}
async countGraphPseudoPreviewEntries(): Promise<number> {

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

@@ -39,6 +39,16 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -133,6 +133,50 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets does not filter by exclude_tags (parameter removed)', async ({
comfyPage,
assetApi
}) => {
// Regression guard: exclude_tags filtering was removed from handleListAssets
// and getFilteredAssets. Passing exclude_tags must NOT exclude any assets —
// all assets matching include_tags (or all assets when include_tags is
// absent) should be returned regardless of the exclude_tags param.
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'tagged-input',
tags: ['input', 'extra-tag']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input&exclude_tags=extra-tag`
)
const data = body as { assets: Array<{ id: string }> }
// Both assets share the 'input' include_tag. With exclude_tags removed,
// neither should be hidden — the response must contain both.
expect(data.assets).toHaveLength(2)
})
test('GET /assets with empty include_tags returns all assets', async ({
comfyPage,
assetApi
}) => {
// When include_tags is absent or empty the helper returns the full store.
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
withAsset(STABLE_INPUT_IMAGE)
)
await assetApi.mock()
const { body } = await assetApi.fetch(`${comfyPage.url}/api/assets`)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(3)
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

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

@@ -0,0 +1,123 @@
import { test, expect } from '@playwright/test'
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
// These tests verify the current state of getMimeType after audio MIME types
// (mp3, flac, ogg, opus) were removed in favour of 'application/octet-stream'.
test.describe('getMimeType', { tag: '@unit' }, () => {
test.describe('image formats (still supported)', () => {
test('returns image/png for .png files', () => {
expect(getMimeType('photo.png')).toBe('image/png')
})
test('returns image/jpeg for .jpg files', () => {
expect(getMimeType('photo.jpg')).toBe('image/jpeg')
})
test('returns image/jpeg for .jpeg files', () => {
expect(getMimeType('photo.jpeg')).toBe('image/jpeg')
})
test('returns image/webp for .webp files', () => {
expect(getMimeType('image.webp')).toBe('image/webp')
})
test('returns image/svg+xml for .svg files', () => {
expect(getMimeType('icon.svg')).toBe('image/svg+xml')
})
test('returns image/avif for .avif files', () => {
expect(getMimeType('image.avif')).toBe('image/avif')
})
})
test.describe('video formats (still supported)', () => {
test('returns video/webm for .webm files', () => {
expect(getMimeType('video.webm')).toBe('video/webm')
})
test('returns video/mp4 for .mp4 files', () => {
expect(getMimeType('video.mp4')).toBe('video/mp4')
})
})
test.describe('other formats (still supported)', () => {
test('returns application/json for .json files', () => {
expect(getMimeType('data.json')).toBe('application/json')
})
test('returns model/gltf-binary for .glb files', () => {
expect(getMimeType('model.glb')).toBe('model/gltf-binary')
})
})
test.describe('audio formats (no longer supported — regression guard)', () => {
// These formats were explicitly removed from getMimeType. They must now
// fall through to the generic octet-stream default, not return audio/*
// MIME types.
test('returns application/octet-stream for .mp3 files', () => {
expect(getMimeType('audio.mp3')).toBe('application/octet-stream')
})
test('returns application/octet-stream for .flac files', () => {
expect(getMimeType('audio.flac')).toBe('application/octet-stream')
})
test('returns application/octet-stream for .ogg files', () => {
expect(getMimeType('audio.ogg')).toBe('application/octet-stream')
})
test('returns application/octet-stream for .opus files', () => {
expect(getMimeType('audio.opus')).toBe('application/octet-stream')
})
})
test.describe('unknown / fallback extensions', () => {
test('returns application/octet-stream for unknown extensions', () => {
expect(getMimeType('file.xyz')).toBe('application/octet-stream')
})
test('returns application/octet-stream for files with no extension', () => {
expect(getMimeType('README')).toBe('application/octet-stream')
})
test('returns application/octet-stream for empty string', () => {
expect(getMimeType('')).toBe('application/octet-stream')
})
})
test.describe('case insensitivity', () => {
test('handles uppercase .PNG extension', () => {
expect(getMimeType('IMAGE.PNG')).toBe('image/png')
})
test('handles uppercase .JPG extension', () => {
expect(getMimeType('PHOTO.JPG')).toBe('image/jpeg')
})
test('handles uppercase .MP4 extension', () => {
expect(getMimeType('VIDEO.MP4')).toBe('video/mp4')
})
test('handles uppercase .WEBM extension', () => {
expect(getMimeType('VIDEO.WEBM')).toBe('video/webm')
})
// Boundary case: confirm audio formats are not supported regardless of case
test('returns application/octet-stream for uppercase .MP3 extension', () => {
expect(getMimeType('AUDIO.MP3')).toBe('application/octet-stream')
})
})
test.describe('filenames with multiple dots', () => {
test('resolves extension from last segment', () => {
expect(getMimeType('my.file.name.png')).toBe('image/png')
})
test('returns octet-stream when final segment is not a known extension', () => {
expect(getMimeType('archive.tar.gz')).toBe('application/octet-stream')
})
})
})

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 ({

View File

@@ -558,5 +558,52 @@ test.describe(
.toBe(0)
})
})
test.fail(
'Promoted text widget is removed when source node is deleted inside the subgraph',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.first()
await expect(subgraphNode).toBeVisible()
const subgraphNodeId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('text')
await expect(
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeVisible()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
)
await interiorClip.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNodeAfter =
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(subgraphNodeAfter).toBeVisible()
await expect(
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
).toBeHidden()
}
)
}
)

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
test.describe('Closing a modified workflow tab (FE-419)', () => {
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
await page.evaluate(() => {
const graph = window.app?.graph
const node = window.LiteGraph?.createNode('Note')
if (graph && node) graph.add(node)
})
await expect(
activeTab.getByTestId('workflow-dirty-indicator')
).toHaveCount(1)
}
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Close anyway' })
).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
0
)
})
test('clicking "Close anyway" closes the tab without saving', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await comfyPage.page
.getByRole('dialog')
.getByRole('button', { name: 'Close anyway' })
.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
await expect
.poll(() => topbar.getActiveTabName())
.toContain('Unsaved Workflow')
})
test('dismissing the dialog keeps the modified tab open', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.newWorkflowButton.click()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
})
})
})

View File

@@ -1,3 +1,5 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -39,6 +41,19 @@ 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, {
@@ -90,6 +105,63 @@ 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

@@ -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,4 +1,5 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -42,4 +43,43 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

@@ -55,7 +55,7 @@
</div>
<Button
v-if="type !== 'info'"
v-if="type !== 'info' && type !== 'dirtyClose'"
variant="secondary"
autofocus
@click="onCancel"
@@ -86,9 +86,9 @@
<template v-else-if="type === 'dirtyClose'">
<Button variant="secondary" @click="onDeny">
<i class="pi pi-times" />
{{ $t('g.no') }}
{{ denyLabel ?? $t('g.no') }}
</Button>
<Button @click="onConfirm">
<Button autofocus @click="onConfirm">
<i class="pi pi-save" />
{{ $t('g.save') }}
</Button>
@@ -131,6 +131,7 @@ const props = defineProps<{
onConfirm: (value?: boolean) => void
itemList?: string[]
hint?: string
denyLabel?: string
}>()
const { t } = useI18n()

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

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

@@ -23,6 +23,7 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
data-testid="workflow-dirty-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>

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

@@ -0,0 +1,195 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
logout: vi.fn().mockResolvedValue(undefined)
}))
const mockToastStore = vi.hoisted(() => ({
add: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
modifiedWorkflows: [] as ModifiedWorkflow[]
}))
const mockWorkflowService = vi.hoisted(() => ({
saveWorkflow: vi.fn().mockResolvedValue(true)
}))
const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => mockWorkflowService)
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: false },
isFreeTier: { value: true },
type: { value: 'free' }
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
})
}))
function makeWorkflow(path: string): ModifiedWorkflow {
return { path, isModified: true } satisfies ModifiedWorkflow
}
describe('useAuthActions.logout', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('logs out without prompting when no workflows are modified', async () => {
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).not.toHaveBeenCalled()
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when the dialog is dismissed (null)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
})
it('cancels sign-out when saving a workflow is cancelled', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('does not log out if a workflow save fails', async () => {
mockWorkflowStore.modifiedWorkflows = [
makeWorkflow('a.json'),
makeWorkflow('b.json')
]
mockDialogService.confirm.mockResolvedValueOnce(true)
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
new Error('disk full')
)
const { logout } = useAuthActions()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
mockWorkflowStore.modifiedWorkflows = workflows
mockDialogService.confirm.mockResolvedValueOnce(true)
const { logout } = useAuthActions()
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
1,
workflows[0]
)
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
2,
workflows[1]
)
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
expect(
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
})
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
mockDialogService.confirm.mockResolvedValueOnce(null)
const { logout } = useAuthActions()
await logout()
expect(mockDialogService.confirm).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dirtyClose',
title: 'auth.signOut.unsavedChangesTitle',
message: 'auth.signOut.unsavedChangesMessage',
denyLabel: 'auth.signOut.signOutAnyway'
})
)
})
})

View File

@@ -9,6 +9,7 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
@@ -53,14 +54,30 @@ export const useAuthActions = () => {
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const modifiedWorkflows = workflowStore.modifiedWorkflows
if (modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
type: 'dirtyClose',
denyLabel: t('auth.signOut.signOutAnyway')
})
if (!confirmed) return
if (confirmed === null) return
if (confirmed === true) {
const workflowService = useWorkflowService()
for (const workflow of modifiedWorkflows) {
try {
const saved = await workflowService.saveWorkflow(workflow)
if (!saved) return
} catch {
throw new Error(
t('auth.signOut.saveFailed', { workflow: workflow.path })
)
}
}
}
}
await authStore.logout()

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,234 @@ 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(),
emitModelReady: 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

@@ -979,6 +979,7 @@
"dirtyCloseTitle": "Save Changes?",
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
"dirtyCloseHint": "Hold Shift to close without prompt",
"dirtyCloseAnyway": "Close anyway",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"workflowTreeType": {
@@ -2211,7 +2212,9 @@
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?",
"signOutAnyway": "Sign out anyway",
"saveFailed": "Sign-out cancelled because saving \"{workflow}\" failed."
},
"passwordUpdate": {
"success": "Password Updated",

View File

@@ -0,0 +1,220 @@
import { render, screen } from '@testing-library/vue'
import type * as VueUseCore from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import Media3DTop from './Media3DTop.vue'
const {
mockUseIntersectionObserver,
mockFindServerPreviewUrl,
mockIsAssetPreviewSupported
} = vi.hoisted(() => ({
mockUseIntersectionObserver: vi.fn(),
mockFindServerPreviewUrl: vi.fn(),
mockIsAssetPreviewSupported: vi.fn(() => true)
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUseCore>()
return {
...actual,
useIntersectionObserver: mockUseIntersectionObserver
}
})
vi.mock('../utils/assetPreviewUtil', () => ({
findServerPreviewUrl: mockFindServerPreviewUrl,
isAssetPreviewSupported: mockIsAssetPreviewSupported
}))
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'mesh.glb',
asset_hash: null,
mime_type: 'model/gltf-binary',
tags: [],
kind: '3D',
src: 'http://example.com/mesh.glb',
...overrides
} as AssetMeta
}
function fireObserverIntersecting() {
mockUseIntersectionObserver.mockImplementation(
(
_target: unknown,
callback: (entries: { isIntersecting: boolean }[]) => void
) => {
callback([{ isIntersecting: true }])
return { stop: vi.fn() }
}
)
}
function noopObserver() {
mockUseIntersectionObserver.mockImplementation(() => ({ stop: vi.fn() }))
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
const globalConfig = { mocks: { $t: (key: string) => key } }
describe('Media3DTop', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAssetPreviewSupported.mockReturnValue(true)
})
it('renders the placeholder when no thumbnail has loaded', () => {
noopObserver()
const { container } = render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- <img> has no role until src is set
expect(container.querySelector('img')).not.toBeInTheDocument()
expect(
screen.getByText('assetBrowser.media.threeDModelPlaceholder')
).toBeInTheDocument()
})
it('uses asset.preview_url directly when preview_id is already on the prop', async () => {
fireObserverIntersecting()
const { container } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: 'p1',
preview_url: 'http://server/preview.png'
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/preview.png')
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('queries the server for a preview when preview_id is missing on the prop', async () => {
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue('http://server/from-name.png')
const { container } = render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
await flush()
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('mesh.glb')
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/from-name.png')
})
it('skips the server query when isAssetPreviewSupported is false', async () => {
fireObserverIntersecting()
mockIsAssetPreviewSupported.mockReturnValue(false)
render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
await flush()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('picks up a patched preview_url after the IntersectionObserver gate has closed', async () => {
// Initial render: observer fires, server has no preview yet — hasAttempted=true
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue(null)
const { container, rerender } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: null,
preview_url: undefined
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).not.toBeInTheDocument()
// Simulate persistThumbnail patching the store: the prop arrives with the
// new preview. The watch on [preview_id, preview_url] should apply it
// directly even though the observer won't refire.
await rerender({
asset: makeAsset({
preview_id: 'p-new',
preview_url: 'http://server/patched.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'http://server/patched.png')
})
it('does not overwrite a thumbnail that is already showing', async () => {
fireObserverIntersecting()
const { container, rerender } = render(Media3DTop, {
props: {
asset: makeAsset({
preview_id: 'p1',
preview_url: 'http://server/first.png'
})
},
global: globalConfig
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).toHaveAttribute(
'src',
'http://server/first.png'
)
await rerender({
asset: makeAsset({
preview_id: 'p2',
preview_url: 'http://server/second.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).toHaveAttribute(
'src',
'http://server/first.png'
)
})
it('does not apply a patch with no preview_id', async () => {
fireObserverIntersecting()
mockFindServerPreviewUrl.mockResolvedValue(null)
const { container, rerender } = render(Media3DTop, {
props: { asset: makeAsset({ preview_id: null, preview_url: undefined }) },
global: globalConfig
})
await flush()
await rerender({
asset: makeAsset({
preview_id: null,
preview_url: 'http://server/no-id.png'
})
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('img')).not.toBeInTheDocument()
})
})

View File

@@ -74,5 +74,20 @@ watch(
}
)
watch(
() => [asset?.preview_id, asset?.preview_url] as const,
([newPreviewId, newPreviewUrl], [, oldPreviewUrl]) => {
if (
newPreviewId &&
newPreviewUrl &&
newPreviewUrl !== oldPreviewUrl &&
!thumbnailSrc.value
) {
thumbnailSrc.value = newPreviewUrl
hasAttempted.value = true
}
}
)
onBeforeUnmount(revokeThumbnail)
</script>

View File

@@ -1,3 +1,4 @@
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -20,11 +21,11 @@ const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true })
.extend({
assets: z.array(zAsset)
})
const zModelFolder = z.object({
name: z.string(),

View File

@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetResponse
} from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
assetService,
@@ -53,6 +56,11 @@ const validBlake3Hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
}
function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
@@ -64,6 +72,13 @@ function buildResponse(
} as unknown as Response
}
function buildAssetListResponse(
assets: AssetItem[],
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
@@ -218,7 +233,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -240,7 +255,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -301,7 +316,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -327,7 +342,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -421,17 +436,14 @@ describe(assetService.getAssetModelFolders, () => {
vi.clearAllMocks()
})
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
]
})
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
)
const folders = await assetService.getAssetModelFolders()
@@ -444,6 +456,7 @@ describe(assetService.getAssetModelFolders, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.has('include_public')).toBe(false)
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -490,14 +503,9 @@ describe(assetService.getAssetsByTag, () => {
vi.clearAllMocks()
})
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
it('forwards include_public=true by default and requests missing-tag exclusion', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
]
})
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
)
const assets = await assetService.getAssetsByTag('input')
@@ -507,6 +515,20 @@ describe(assetService.getAssetsByTag, () => {
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('normalizes tag query parameters', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'visible', tags: ['input'] })])
)
await assetService.getAssetsByTag(' input ')
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('input')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
})
@@ -518,17 +540,16 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
buildAssetListResponse(
[
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
],
{ hasMore: true }
)
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -540,63 +561,33 @@ describe(assetService.getAllAssetsByTag, () => {
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
buildAssetListResponse(
[
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
has_more: true
})
{ hasMore: true }
)
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -614,12 +605,41 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
it.each([
{
name: 'missing has_more',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1
}
},
{
name: 'missing total',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
has_more: false
}
},
{
name: 'non-boolean has_more',
body: {
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1,
has_more: 'false'
}
}
])('rejects asset responses with $name', async ({ body }) => {
fetchApiMock.mockResolvedValueOnce(buildResponse(body))
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 2 })
).rejects.toThrow(/Invalid asset response/)
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
)
await assetService.getAllAssetsByTag('input', true, {
@@ -636,12 +656,13 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildResponse({
assets: [
return buildAssetListResponse(
[
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
],
{ hasMore: true }
)
})
await expect(
@@ -666,7 +687,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -685,8 +706,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -720,7 +741,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -750,7 +771,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -770,12 +791,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -788,9 +809,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -809,9 +830,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -827,7 +848,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -849,7 +870,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()

View File

@@ -36,6 +36,7 @@ interface AssetPaginationOptions extends PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
signal?: AbortSignal
}
@@ -181,6 +182,7 @@ const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
/** Result of a HEAD lookup against an exact asset hash. */
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
@@ -210,6 +212,10 @@ function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
function normalizeAssetTags(tags: string[]): string[] {
return tags.map((tag) => tag.trim()).filter(Boolean)
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
@@ -290,15 +296,22 @@ function createAssetService() {
): Promise<AssetResponse> {
const {
includeTags,
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
includePublic,
signal
} = options
const normalizedIncludeTags = normalizeAssetTags(includeTags)
const normalizedExcludeTags = normalizeAssetTags(excludeTags)
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
include_tags: normalizedIncludeTags.join(','),
limit: limit.toString()
})
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
@@ -337,15 +350,10 @@ function createAssetService() {
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
data?.assets
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
) ?? []
)
const folderTags = data.assets
.flatMap((asset) => asset.tags)
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
const discoveredFolders = new Set<string>(folderTags)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
@@ -363,17 +371,10 @@ function createAssetService() {
`models for ${folder}`
)
return (
data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name,
pathIndex: 0
})) ?? []
)
return data.assets.map((asset) => ({
name: asset.name,
pathIndex: 0
}))
}
/**
@@ -449,12 +450,7 @@ function createAssetService() {
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
return (
data?.assets?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
) ?? []
)
return data.assets
}
/**
@@ -473,11 +469,8 @@ function createAssetService() {
}
const data = await res.json()
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const result = assetItemSchema.safeParse(data)
if (result.success) return result.data
const error = result.error
? fromZodError(result.error)
@@ -508,13 +501,12 @@ function createAssetService() {
`assets for tag ${tag}`
)
return (
data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
)
return data.assets
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
* Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -545,13 +537,14 @@ function createAssetService() {
},
`assets for tag ${tag}`
)
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
const batch = data.assets
if (batch.length === 0) {
return assets
}
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
assets.push(...batch)
if (!data.has_more) {
return assets
}

View File

@@ -15,6 +15,7 @@ const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
const mockSetAssetPreview = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -33,6 +34,10 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ setAssetPreview: mockSetAssetPreview })
}))
function mockFetchResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
@@ -264,4 +269,66 @@ describe('persistThumbnail', () => {
preview_id: 'new-preview-id'
})
})
it('patches the assets store by name with the new preview after upload', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).toHaveBeenCalledWith(
localAsset.name,
'new-preview-id',
'http://localhost:8188/assets/new-preview-id/content'
)
})
it('uses the cloud asset name (not the hash) when patching the store', async () => {
mockFetchResponse([cloudAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('c6cadcee57dd.glb', blob)
expect(mockSetAssetPreview).toHaveBeenCalledWith(
cloudAsset.name,
'new-preview-id',
'http://localhost:8188/assets/new-preview-id/content'
)
})
it('does not patch the store when the asset already has a preview', async () => {
mockFetchEmpty()
mockFetchResponse([localAssetWithPreview])
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
it('does not patch the store when no asset is found', async () => {
mockFetchEmpty()
mockFetchEmpty()
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('nonexistent.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
it('does not patch the store when upload fails', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockSetAssetPreview).not.toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,6 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
@@ -80,6 +81,9 @@ export async function persistThumbnail(
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
const previewUrl = api.apiURL(`/assets/${uploaded.id}/content`)
useAssetsStore().setAssetPreview(asset.name, uploaded.id, previewUrl)
} catch {
// Non-critical — client still shows the rendered thumbnail
}

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('buildFeedbackTypeformUrl', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
})
async function build(source: 'topbar' | 'action-bar' | 'help-center') {
vi.resetModules()
const { buildFeedbackTypeformUrl } = await import('./config')
return buildFeedbackTypeformUrl(source)
}
it('tags Cloud builds with distribution=ccloud', async () => {
distribution.isCloud = true
expect(await build('topbar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar'
)
})
it('tags Nightly builds with distribution=oss-nightly', async () => {
distribution.isNightly = true
expect(await build('action-bar')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=action-bar'
)
})
it('tags OSS builds with distribution=oss', async () => {
expect(await build('help-center')).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=oss&source=help-center'
)
})
it('uses a URL fragment so distribution and source are not sent to the server', async () => {
distribution.isCloud = true
const url = new URL(await build('topbar'))
expect(url.search).toBe('')
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})

View File

@@ -15,7 +15,7 @@ const ZENDESK_FIELDS = {
} as const
/**
* Gets the distribution identifier for Zendesk tracking.
* Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
@@ -25,17 +25,22 @@ function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Builds the feedback form URL with the appropriate distribution tag.
* Builds the feedback Typeform URL tagged with the current build distribution
* and the UI source that opened it. Tags are passed via the URL fragment
* (Typeform's hidden-field convention) so survey responses can be segmented
* by distribution (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackUrl(): string {
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
distribution: getDistribution(),
source
})
return `${SUPPORT_BASE_URL}?${params.toString()}`
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**

View File

@@ -418,24 +418,51 @@ describe('useWorkflowService', () => {
})
vi.mocked(workflowStore.saveWorkflow).mockResolvedValue()
await useWorkflowService().saveWorkflow(workflow)
const result = await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(true)
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
})
it('should call saveWorkflowAs for temporary workflows', async () => {
it('should return false when temporary workflow save is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
await useWorkflowService().saveWorkflow(workflow)
const result = await useWorkflowService().saveWorkflow(workflow)
expect(result).toBe(false)
expect(workflowStore.saveWorkflow).not.toHaveBeenCalled()
})
})
describe('closeWorkflow', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
beforeEach(() => {
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
it('keeps a temporary workflow open when Save As is cancelled', async () => {
const workflow = createModeTestWorkflow({
path: 'workflows/Unsaved Workflow.json'
})
workflow.isModified = true
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
vi.spyOn(workflow, 'promptSave').mockResolvedValue(null)
mockConfirm.mockResolvedValue(true)
const closed = await service.closeWorkflow(workflow)
expect(closed).toBe(false)
expect(workflowStore.closeWorkflow).not.toHaveBeenCalled()
})
})
describe('afterLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let existingWorkflow: LoadedComfyWorkflow

View File

@@ -174,40 +174,39 @@ export const useWorkflowService = () => {
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
const saveWorkflow = async (workflow: ComfyWorkflow): Promise<boolean> => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
'/' +
appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return
}
await deleteWorkflow(existing, true)
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return await saveWorkflowAs(workflow)
}
workflow.changeTracker?.prepareForSave()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory + '/' + appendWorkflowJsonExt(workflow.filename, isApp)
if (workflow.path !== expectedPath) {
const existing = workflowStore.getWorkflowByPath(expectedPath)
if (existing && !existing.isTemporary) {
if ((await confirmOverwrite(expectedPath)) !== true) {
await workflowStore.saveWorkflow(workflow)
return true
}
await deleteWorkflow(existing, true)
}
await renameWorkflow(workflow, expectedPath)
toastStore.add({
severity: 'info',
summary: t(
isApp
? 'workflowService.savedAsApp'
: 'workflowService.savedAsWorkflow'
),
life: 3000
})
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
return true
}
/**
@@ -284,13 +283,15 @@ export const useWorkflowService = () => {
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint
hint: options.hint,
denyLabel: t('sideToolbar.workflowTab.dirtyCloseAnyway')
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
await saveWorkflow(workflow)
const saved = await saveWorkflow(workflow)
if (!saved) return false
}
}

View File

@@ -1,11 +1,19 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeFooter from '@/renderer/extensions/vueNodes/components/NodeFooter.vue'
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
return { layoutStore: { isDraggingVueNodes } }
})
const { layoutStore } = await import('@/renderer/core/layout/store/layoutStore')
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -143,6 +151,33 @@ describe('NodeFooter', () => {
await user.click(screen.getByText('Show Advanced Inputs'))
expect(emitted()).toHaveProperty('toggleAdvanced')
})
describe('drag-then-click suppression', () => {
beforeEach(() => {
layoutStore.isDraggingVueNodes.value = false
})
it('does not emit enterSubgraph when a node drag is in progress at pointerup', async () => {
const { emitted } = renderFooter({ isSubgraph: true })
layoutStore.isDraggingVueNodes.value = true
await user.click(screen.getByTestId('subgraph-enter-button'))
expect(emitted().enterSubgraph).toBeUndefined()
})
it('only suppresses the immediately following click, not later ones', async () => {
const { emitted } = renderFooter({ isSubgraph: true })
const button = screen.getByTestId('subgraph-enter-button')
layoutStore.isDraggingVueNodes.value = true
await user.click(button)
expect(emitted().enterSubgraph).toBeUndefined()
layoutStore.isDraggingVueNodes.value = false
await user.click(button)
expect(emitted()).toHaveProperty('enterSubgraph')
})
})
})
describe('shape-based radius classes (getBottomRadius)', () => {

View File

@@ -13,7 +13,8 @@
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -32,7 +33,8 @@
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
@@ -60,7 +62,8 @@
errorRadiusClass
)
"
@click.stop="$emit('openErrors')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -78,7 +81,8 @@
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{
@@ -111,7 +115,8 @@
footerRadiusClass
)
"
@click.stop="$emit('openErrors')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
@@ -142,7 +147,8 @@
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
@@ -172,7 +178,8 @@
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
@pointerup="snapshotDragOnPointerUp"
@click.stop="emitIfNotDragged('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
@@ -197,6 +204,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
@@ -221,12 +229,29 @@ const {
shape
} = defineProps<Props>()
defineEmits<{
const emit = defineEmits<{
enterSubgraph: []
openErrors: []
toggleAdvanced: []
}>()
let suppressNextClick = false
function snapshotDragOnPointerUp() {
suppressNextClick = layoutStore.isDraggingVueNodes.value
}
function emitIfNotDragged(
name: 'enterSubgraph' | 'openErrors' | 'toggleAdvanced'
) {
const wasDrag = suppressNextClick
suppressNextClick = false
if (wasDrag) return
if (name === 'enterSubgraph') emit('enterSubgraph')
else if (name === 'openErrors') emit('openErrors')
else emit('toggleAdvanced')
}
const RADIUS_CLASS = {
'rounded-b-17': 'rounded-b-[17px]',
'rounded-b-20': 'rounded-b-[20px]',

View File

@@ -34,11 +34,21 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
})
}))
const snapState = vi.hoisted(() => ({
shouldSnap: false,
applySnapToPosition: (pos: { x: number; y: number }) => pos,
applySnapToSize: (size: { width: number; height: number }) => size
}))
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
useNodeSnap: () => ({
shouldSnap: vi.fn(() => false),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos),
applySnapToSize: vi.fn((size: { width: number; height: number }) => size)
shouldSnap: vi.fn(() => snapState.shouldSnap),
applySnapToPosition: vi.fn((pos: { x: number; y: number }) =>
snapState.applySnapToPosition(pos)
),
applySnapToSize: vi.fn((size: { width: number; height: number }) =>
snapState.applySnapToSize(size)
)
})
}))
@@ -149,6 +159,9 @@ describe('useNodeResize', () => {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
snapState.shouldSnap = false
snapState.applySnapToPosition = (pos) => pos
snapState.applySnapToSize = (size) => size
callback = vi.fn<ResizeCallback>()
nodeElement = createMockNodeElement()
@@ -273,4 +286,230 @@ describe('useNodeResize', () => {
expect(payload.position!.y).toBe(450)
})
})
describe('dynamic content height (re-measured per move)', () => {
function makeReflowingElement(
width: number,
height: number,
getMinContentHeight: () => number
): HTMLElement {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
const h = nodeHeight === '0px' ? getMinContentHeight() : height
return {
width,
height: h,
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: h,
toJSON: () => {}
} as DOMRect
}
return element
}
async function setupDynamic(getMinContentHeight: () => number) {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
const cb = vi.fn<ResizeCallback>()
const el = makeReflowingElement(300, 400, getMinContentHeight)
const h = createMockHandle(el)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
return { cb, el, handle: h, startResize }
}
it('uses the latest measured content height when content reflows taller', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
// First move: clamp uses initial minContentHeight = 150
simulateMove(0, -300)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
// Content reflows taller (e.g. painter switches to compact layout)
currentMinHeight = 280
// Second move at the same position must reflect the new minimum,
// not the value captured at drag start.
simulateMove(0, -300)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(280)
})
it('also re-measures for N corners and updates the y-position clamp', async () => {
let currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'NW')
simulateMove(0, 500)
const firstPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(firstPayload.size.height).toBe(150)
expect(firstPayload.position!.y).toBe(450) // 200 + 400 - 150
currentMinHeight = 220
simulateMove(0, 500)
const secondPayload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(secondPayload.size.height).toBe(220)
expect(secondPayload.position!.y).toBe(380) // 200 + 400 - 220
})
it('stops responding to pointermove after pointerup', async () => {
const currentMinHeight = 150
const {
cb,
handle: h,
startResize
} = await setupDynamic(() => currentMinHeight)
startResizeAt(startResize, h, 'SE')
simulateMove(20, 20)
const callsBeforeUp = cb.mock.calls.length
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
eventHandlers.pointerup?.(upEvent)
// Subsequent moves should be ignored after cleanup
simulateMove(40, 40)
expect(cb.mock.calls.length).toBe(callsBeforeUp)
})
it('handles releasePointerCapture throwing without breaking cleanup', async () => {
const { cb, el, handle: h, startResize } = await setupDynamic(() => 150)
h.releasePointerCapture = vi.fn(() => {
throw new Error('already released')
})
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
const upEvent = createPointerEvent('pointerup', { pointerId: 1 })
expect(() => eventHandlers.pointerup?.(upEvent)).not.toThrow()
// Further moves are ignored — cleanup still ran.
const callsAfterUp = cb.mock.calls.length
simulateMove(50, 50)
expect(cb.mock.calls.length).toBe(callsAfterUp)
expect(el).toBeDefined()
})
it('applies snap-to-grid on SE (size only)', async () => {
snapState.shouldSnap = true
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'SE')
simulateMove(53, 27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.width).toBe(350) // 353 -> 350
expect(payload.size.height).toBe(430) // 427 -> 430
expect(payload.position).toBeUndefined()
})
it('applies snap-to-grid on NW (position + size compensation)', async () => {
snapState.shouldSnap = true
// Snap position down to nearest 10
snapState.applySnapToPosition = ({ x, y }) => ({
x: Math.floor(x / 10) * 10,
y: Math.floor(y / 10) * 10
})
snapState.applySnapToSize = ({ width, height }) => ({
width: Math.round(width / 10) * 10,
height: Math.round(height / 10) * 10
})
const { cb, handle: h, startResize } = await setupDynamic(() => 50)
startResizeAt(startResize, h, 'NW')
// delta: x=-53, y=-27 -> raw newX=47, newY=173
// applySnapToPosition floors -> {40, 170}
// size compensated: width += 47-40=7 (-> 360), height += 173-170=3 (-> 430)
// applySnapToSize rounds -> 360, 430
simulateMove(-53, -27)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.position).toEqual({ x: 40, y: 170 })
expect(payload.size).toEqual({ width: 360, height: 430 })
})
it('restores --node-height after measuring (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-height', '400px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
// Probe value should be reverted, not left at '0px'
expect(el.style.getPropertyValue('--node-height')).toBe('400px')
})
it('measures with the candidate width applied (responsive breakpoint frame)', async () => {
// Simulate a responsive widget: when width < 350, content reflows to
// 280; when width >= 350, content fits in 150.
const breakpointAwareElement = (() => {
const element = document.createElement('div')
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
const nodeHeight = element.style.getPropertyValue('--node-height')
if (nodeHeight === '0px') {
const widthVar = element.style.getPropertyValue('--node-width')
const probedWidth = parseFloat(widthVar) || 300
const minH = probedWidth < 350 ? 280 : 150
return { width: probedWidth, height: minH } as DOMRect
}
return { width: 300, height: 400 } as DOMRect
}
return element
})()
const cb = vi.fn<ResizeCallback>()
const h = createMockHandle(breakpointAwareElement)
const { useNodeResize } = await import('./useNodeResize')
const { startResize } = useNodeResize(cb)
// Start at width=300 (still narrow side, but the breakpoint logic
// matters when the user attempts to shrink toward narrow on this frame).
breakpointAwareElement.style.setProperty('--node-width', '400px')
startResizeAt(startResize, h, 'SE')
// First move drives newWidth to 340 (below breakpoint). Probe must use
// 340, not the DOM's currently-applied 400, to return 280.
simulateMove(-60, -300)
const payload = cb.mock.calls.at(-1)![0] as ResizeCallbackPayload
expect(payload.size.height).toBe(280)
})
it('restores --node-width after probing (does not clobber state)', async () => {
const { el, handle: h, startResize } = await setupDynamic(() => 150)
el.style.setProperty('--node-width', '350px')
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
expect(el.style.getPropertyValue('--node-width')).toBe('350px')
})
})
})

View File

@@ -59,10 +59,17 @@ export function useNodeResize(
height: rect.height / scale
}
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-height', '0px')
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
const measureMinContentHeight = (candidateWidth: number) => {
const savedWidth = nodeElement.style.getPropertyValue('--node-width')
const savedHeight = nodeElement.style.getPropertyValue('--node-height')
nodeElement.style.setProperty('--node-width', `${candidateWidth}px`)
nodeElement.style.setProperty('--node-height', '0px')
const measured = nodeElement.getBoundingClientRect().height
nodeElement.style.setProperty('--node-height', savedHeight || '')
nodeElement.style.setProperty('--node-width', savedWidth || '')
const currentScale = transformState.camera.z || 1
return measured / currentScale
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
const startPosition: Point = nodeLayout
@@ -165,6 +172,12 @@ export function useNodeResize(
}
newWidth = minWidth
}
// Re-measure on each move with the candidate width applied: widget
// content (e.g. painter controls) can re-flow taller as width shrinks,
// raising the true minimum. Probing with newWidth — not the DOM's
// current width — keeps the clamp accurate on the frame that crosses
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
newY =

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

View File

@@ -42,6 +42,31 @@ export type ConfirmationDialogType =
| 'reinstall'
| 'info'
interface BaseConfirmOptions {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}
type ConfirmOptions = BaseConfirmOptions &
(
| {
/** Pre-configured dialog type */
type: 'dirtyClose'
/** Override the deny button label. Defaults to `g.no`. */
denyLabel?: string
}
| {
/** Pre-configured dialog type */
type?: Exclude<ConfirmationDialogType, 'dirtyClose'>
denyLabel?: never
}
)
/**
* Minimal interface for execution error dialogs.
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
@@ -244,18 +269,9 @@ export const useDialogService = () => {
message,
type = 'default',
itemList = [],
hint
}: {
/** Dialog heading */
title: string
/** The main message body */
message: string
/** Pre-configured dialog type */
type?: ConfirmationDialogType
/** Displayed as an unordered list immediately below the message body */
itemList?: string[]
hint?: string
}): Promise<boolean | null> {
hint,
denyLabel
}: ConfirmOptions): Promise<boolean | null> {
return new Promise((resolve) => {
const options: ShowDialogOptions = {
key: 'global-prompt',
@@ -266,7 +282,8 @@ export const useDialogService = () => {
type,
itemList,
onConfirm: resolve,
hint
hint,
denyLabel
},
dialogComponentProps: {
onClose: () => resolve(null)

View File

@@ -542,6 +542,88 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('setAssetPreview', () => {
it('patches preview_id and preview_url on the matching history asset by name', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const target = store.historyAssets[1]
const targetName = target.name
store.setAssetPreview(
targetName,
'preview-xyz',
'/assets/preview-xyz/content'
)
const updated = store.historyAssets.find((a) => a.name === targetName)
expect(updated?.preview_id).toBe('preview-xyz')
expect(updated?.preview_url).toBe('/assets/preview-xyz/content')
})
it('matches by name even when ids differ between APIs', async () => {
const mockHistory = [createMockJobItem(0)]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const historyAssetId = store.historyAssets[0].id
const targetName = store.historyAssets[0].name
// Simulate the cloud-api side using a different id space
store.setAssetPreview(targetName, 'p1', '/assets/p1/content')
expect(store.historyAssets[0].id).toBe(historyAssetId)
expect(store.historyAssets[0].preview_id).toBe('p1')
expect(store.historyAssets[0].preview_url).toBe('/assets/p1/content')
})
it('does nothing when no asset with that name is loaded', async () => {
const mockHistory = Array.from({ length: 2 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const before = store.historyAssets.map((a) => ({ ...a }))
store.setAssetPreview('does-not-exist.glb', 'p', '/p')
expect(store.historyAssets).toEqual(before)
})
it('only patches the asset whose name matches exactly', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
// Patch using a non-matching prefix; the other assets must stay untouched
store.setAssetPreview('output_1', 'p', '/p')
for (const asset of store.historyAssets) {
expect(asset.preview_id).toBeUndefined()
}
})
it('replaces the asset object so reactivity fires for v-for keyed by id', async () => {
const mockHistory = [createMockJobItem(0)]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
const before = store.historyAssets[0]
const targetName = before.name
store.setAssetPreview(targetName, 'p', '/p')
// setAssetPreview replaces the item with a new object via list[idx] = {...}
// (rather than mutating in place) so Vue triggers dependent watchers.
expect(store.historyAssets[0]).not.toBe(before)
})
})
describe('jobDetailView Support', () => {
it('should include outputCount and allOutputs in user_metadata', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>

View File

@@ -255,6 +255,32 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
/**
* Patch preview_id/preview_url for a single asset already in memory,
* matched by name. Used after persistThumbnail succeeds so an open Asset
* panel reflects the new thumbnail without refetching the whole history.
* Match by name because the cloud assets API and the history API use
* different id spaces; name is the stable cross-API identifier.
*/
const setAssetPreview = (
name: string,
previewId: string,
previewUrl: string
) => {
const patch = (list: AssetItem[]) => {
const idx = list.findIndex((a) => a.name === name)
if (idx < 0) return
list[idx] = {
...list[idx],
preview_id: previewId,
preview_url: previewUrl
}
}
patch(historyAssets.value)
patch(allHistoryItems.value)
patch(inputAssets.value)
}
/**
* Map of asset hash filename to asset item for O(1) lookup
* Cloud assets use asset_hash for the hash-based filename
@@ -782,6 +808,7 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
setAssetPreview,
// Input mapping helpers
inputAssetsByFilename,