Compare commits

...

24 Commits

Author SHA1 Message Date
Deep Mehta
044d4de76a fix: progressive hierarchical fallback and HF-download node dropdowns
1. Fix findProvidersWithFallback to try all intermediate parent paths
   (a/b/c → a/b → a) instead of jumping to top-level only. This
   ensures "Use" on CogVideo/ControlNet/* models creates the correct
   DownloadAndLoadCogVideoControlNet node.

2. Use empty key for DownloadAndLoadCogVideoControlNet and
   DownloadAndLoadCogVideoModel so the asset browser doesn't hijack
   the combo widget — these nodes use HF repo names, not file assets.

3. Add comprehensive tests for 1-4 level nested path fallback.
2026-03-18 09:35:17 -07:00
pythongosssss
e7384ab2da feat: App mode - update keybindings (#9794)
## Summary

Improve app mode discoverability/usability

## Changes

- **What**:
- toggle linear rebound as alt+m
- toggle minimap rebound as alt+shift+m
- show keybinding for toggling mode on button

- **Breaking**:
- Toggle minimap shortcut changed from ALT+M to ALT+SHIFT+M

## Screenshots (if applicable)

<img width="302" height="145" alt="image"
src="https://github.com/user-attachments/assets/5f2df599-c0db-4df1-ba22-d7ee9eb6b662"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9794-feat-App-mode-update-keybindings-3216d73d3650811eac37f226b381e54a)
by [Unito](https://www.unito.io)
2026-03-18 08:32:36 -07:00
pythongosssss
190bbf0ac2 feat: App mode - enable mask editor (#9876)
## Summary

Adds the ability for users to access mask editor from app mode

## Changes

- **What**: 
- add mask editor button to app mode load image
- extract parseImageWidgetValue for url + test

## Screenshots (if applicable)

<img width="303" height="232" alt="image"
src="https://github.com/user-attachments/assets/89afffda-d049-4ef4-ba13-bf483222b27c"
/>
<img width="959" height="715" alt="image"
src="https://github.com/user-attachments/assets/db19a327-e53b-4047-aa66-2cb6e889be1d"
/>
<img width="308" height="302" alt="image"
src="https://github.com/user-attachments/assets/efaf601a-c2d8-4098-a3aa-1b5bbb3aec1c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9876-feat-App-mode-enable-mask-editor-3226d73d365081d5982bf5e0e870f9b3)
by [Unito](https://www.unito.io)
2026-03-18 03:08:26 -07:00
jaeone94
a3cf6fcde0 fix: convert download URLs to browsable page URLs in Copy Url button (#10228)
## Summary

- Copy Url button in MissingModelRow now copies a browsable page URL
instead of a direct download URL
- HuggingFace: `/resolve/` → `/blob/` (file page with model info and
download button)
- Civitai: strips `/api/download` or `/api/v1` prefix (model page)

## Changes

- Add `toBrowsableUrl()` to `missingModelDownload.ts` — converts
download URLs to browsable page URLs for HuggingFace and Civitai
- Update `MissingModelRow.vue` to use `toBrowsableUrl()` when copying
- Add 5 unit tests covering HuggingFace, Civitai, and non-matching URL
cases

## Test plan

- [x] Unit tests pass (14/14) — `toBrowsableUrl` covered by 5 dedicated
tests
- [x] Lint, format, typecheck pass
- [x] Manual: load workflow with missing HuggingFace models, click Copy
Url, verify copied URL opens the file page
- [x] Manual: load workflow with missing Civitai models, click Copy Url,
verify copied URL opens the model page

### Why no E2E test

The Copy Url button is only visible when `!isCloud &&
model.representative.url && !isAssetSupported`. Writing an E2E test for
the clipboard content would require:

1. A test fixture with a real HuggingFace/Civitai URL (fragile — depends
on external availability)
2. Granting `clipboard-read` permission in Playwright context
3. Ensuring `isAssetSupported` evaluates to `false` against the local
test server's node definitions

The URL transformation logic is a pure function, fully covered by unit
tests. E2E clipboard content verification has no existing patterns in
the codebase and would be environment-dependent and flaky.

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-18 13:19:52 +09:00
Jin Yi
942938d058 [refactor] Rename ResultGallery to MediaLightbox and address code review (#10235)
## Summary
Address code review feedback from #10134 by renaming the component and
improving implementation quality.

## Changes
- Rename `ResultGallery` → `MediaLightbox` across all references
- Replace `useEventListener(window, 'keydown')` with `@keydown` on
dialog element
- Remove change detector tests (`renders close button`, `prevents
default on arrow keys`)
- Remove redundant `toBeVisible()` before Playwright click (implicit
wait)
- Update keyboard tests to dispatch on dialog element instead of
`window`
- Sort button icon sizes (`icon-sm`, `icon`, `icon-lg`)
- Wire zoom event to lightbox in `MediaAssetCard` story via
`context.args`
- Add standalone `MediaLightbox` Storybook story under
`Platform/Assets/`

Fixes #10134

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10235-refactor-Rename-ResultGallery-to-MediaLightbox-and-address-code-review-3276d73d365081299b42f682373a12f1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:02:08 +09:00
Deep Mehta
af7bc38e31 feat: add model-to-node mappings for LTX Video prompt enhancer (#10234)
## Summary
- Add `quickRegister()` entries for LTX Video prompt enhancer models:
- `LLM/Llama-3.2-3B-Instruct` → `LTXVPromptEnhancerLoader` (`llm_name`)
- `LLM/Florence-2-large-PromptGen-v2.0` → `LTXVPromptEnhancerLoader`
(`image_captioner_name`)

These mappings ensure the "Use" button in the model browser correctly
creates
the appropriate loader node when users click on these LLM models.

## Test plan
- [x] Verify "Use" button works for LLM/Llama-3.2-3B-Instruct in model
browser
- [x] Verify "Use" button works for LLM/Florence-2-large-PromptGen-v2.0
in model browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10234-feat-add-model-to-node-mappings-for-LTX-Video-prompt-enhancer-3276d73d36508154847cdbae8c6e9bf1)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:02:01 -07:00
Jin Yi
3c9b048974 refactor: replace PrimeVue Galleria with custom overlay in ResultGallery (#10134)
## Changes

- Replace PrimeVue `Galleria` component with a custom Tailwind-based
fullscreen overlay in `ResultGallery.vue`
- Use design system `Button` component with new `icon-lg` size variant
- Add `role="dialog"` and `aria-modal="true"` for accessibility
- Add `event.preventDefault()` on keyboard handlers
- Use VueUse `useEventListener` for auto-cleanup
- Add i18n keys for aria-labels (`g.previous`, `g.close`, `g.next`)
- Add e2e Playwright test for ResultGallery

## Testing

- 11 unit tests passing
- e2e test added for CI validation


[screen-capture.webm](https://github.com/user-attachments/assets/6401e781-9897-4586-8ed3-5251d957ce5b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10134-refactor-replace-PrimeVue-Galleria-with-custom-overlay-in-ResultGallery-3266d73d365081309042c5c2b7fbc3b0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 11:14:10 +09:00
Christian Byrne
0e5bd539ec fix: 3D asset disappears when switching to image output in app mode (#9622)
## Summary

Fix 3D asset disappearing when switching between 3D and image outputs in
app mode — missing `onUnmounted` cleanup leaked WebGL contexts.

## Changes

- **What**: Add `onUnmounted` hook to `Preview3d.vue` that calls
`viewer.cleanup()`, releasing the WebGL context when Vue destroys the
component via its v-if chain. Add unit tests covering init, cleanup on
unmount, and remount behavior.

## Review Focus

When switching outputs in app mode, Vue's v-if chain destroys and
recreates `Preview3d`. Without `onUnmounted` cleanup, the old `Load3d`
instance (WebGL context, RAF loop, ResizeObserver) leaks. After ~8-16
toggles, the browser's WebGL context limit is exhausted and new 3D
viewers silently fail to render.

<!-- Pipeline-Ticket: e36489d2-a9fb-47ca-9e27-88eb3170836b -->

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 01:08:04 +00:00
Egor
c2cf8e4fb1 perf: speed up local inference on shared GPU (#9968)
## Summary

Allows to disable UI animations to speed up local inference on shared
GPU setups. Gives 7-13% improvement

## Changes

- **What**: New setting under Appearance > General. Applies a CSS class
that disables all animations and transitions
- **Breaking**: None since it's opt in

I also considered auto-disabling animations only during generation on
shared GPU setups. We could detect that, lmk if you want it to be
implemented.

## Screenshots

Mac with Apple Silicon

<img width="362" height="369" alt="Screenshot 2026-03-16 at 01 15 03"
src="https://github.com/user-attachments/assets/aa9e3dcf-15cf-48e9-a4f5-834373bc0400"
/>
<img width="866" height="585" alt="Screenshot 2026-03-16 at 01 42 34"
src="https://github.com/user-attachments/assets/aca13c04-aa98-41bf-a5ff-4e218b9c6460"
/>

## How to reproduce:

- Run frontend & backend locally on a device with shared GPU
- Install the model: `cd models/checkpoints && curl -L -o
v1-5-pruned-emaonly-fp16.safetensors
"https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emao
  nly-fp16.safetensors"` (~2GB)
- Use this workflow: https://cloud.comfy.org/?share=4086706e0018 (also
attached):

[test-workflow.json](https://github.com/user-attachments/files/26006974/test-workflow.json)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9968-perf-speed-up-local-inference-on-shared-GPU-3246d73d365081adaa03c348a7efdc1e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 00:40:51 +00:00
Deep Mehta
5ae526dc39 fix: use server response filename in WebcamCapture serialization (#10220)
## Summary
- WebcamCapture's `serializeValue` was ignoring the `/upload/image`
response and returning the original timestamp-based filename
(`webcam/${timestamp}.png [temp]`)
- This breaks content-addressed storage backends (e.g. Comfy Cloud)
where the server returns a different (hash-based) filename, causing
`ImageDownloadError` at inference time
- Now reads `data.name` and `data.subfolder` from the upload response,
matching the pattern already used by `useNodeImageUpload` and
`useMaskEditorSaver`
- No behavioral change for local ComfyUI — the server already returns
the same filename, it was just being ignored

## Test plan
- [x] WebcamCapture node captures and uploads an image successfully on
local ComfyUI
- [x] WebcamCapture node works correctly on Comfy Cloud (no
`ImageDownloadError`)
- [ ] Duplicate filename edge case: upload two captures with same
timestamp — server-renamed file is used correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10220-fix-use-server-response-filename-in-WebcamCapture-serialization-3266d73d3650818aa9bdff8d27586180)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:47:30 -07:00
Christian Byrne
a00e4b6421 fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10203)
## Summary

`queueStore.update()` previously used a request-ID guard that discarded
responses whenever a newer call had been initiated. During concurrent
job execution, rapid WebSocket events (`status`, `execution_success`,
etc.) created a sustained stream of `update()` calls where:

1. **Every response got discarded** — each returning response saw a
higher `updateRequestId`, so data was never applied to the store until
the event burst subsided
2. **All HTTP requests still fired** — the guard didn't suppress
outgoing calls, just discarded stale responses after the fact

This caused the UI to freeze showing stale job states (e.g. completed
jobs still showing as "Running") during bursts, then snap all at once
when the storm ended.

## Changes

Replace the request-ID guard with a single-flight coalescing pattern:

- **At most one fetch in flight** at a time — no request spam
- **Every response is applied** — no UI starvation
- **Dirty flag** triggers one re-fetch if calls arrived during flight —
eventual consistency

## Testing

Added 5 unit tests covering:
- Concurrent calls coalesce into a single re-fetch
- No response starvation (every response applied)
- No duplicate requests when no concurrent calls
- Loading state transitions through coalesced re-fetches
- Normal sequential behavior preserved

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10203-fix-replace-stale-request-guard-with-single-flight-coalescing-in-queueStore-update-3266d73d365081088656e4e55ca4dbd3)
by [Unito](https://www.unito.io)
2026-03-17 14:26:59 -07:00
Terry Jia
2e5e04efd5 fix: enable 3D thumbnail support for cloud environments (#10121)
## Summary
The 3D thumbnail logic was gated behind `api.getServerFeature('assets',
false)` which only covers local servers. Use `isAssetPreviewSupported()`
to also cover cloud via `assetService.isAssetAPIEnabled()`.

follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/9471

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10121-fix-enable-3D-thumbnail-support-for-cloud-environments-3256d73d365081c18975e917c604582b)
by [Unito](https://www.unito.io)
2026-03-17 13:36:01 -07:00
Kelly Yang
55f1081874 fix: store 3d viewer config in standalone mode (#10126)
## Summary

Adds settings persistence for the standalone 3D viewer (App Mode),
ensuring custom configurations like background color and camera state
are remembered across different models.

## Changes

URL-based Caching: Added a cache to store and restore viewer settings
per model URL.
Unit Tests: Added coverage for configuration saving and restoration
workflows.


## Screenshots
before


https://github.com/user-attachments/assets/5ba0e3a1-c876-4de6-879e-e77c42661267

after


https://github.com/user-attachments/assets/debc4fbd-b5ae-484e-aa67-d4c130722ab0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10126-fix-persistence-for-3d-viewer-config-in-standalone-mode-3266d73d365081e88438ca29d4db5640)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-17 13:21:17 -07:00
Christian Byrne
f9b0f277bf fix: track nodePreviewImages in usePromotedPreviews (#10165)
## Summary

The `computed` in `usePromotedPreviews` only tracked `nodeOutputs` as a
reactive dependency. GLSL live previews (and other preview-only sources)
write to `nodePreviewImages` instead of `nodeOutputs`, so promoted
preview widgets on SubgraphNodes never re-evaluated when live previews
updated.

## Changes

**Production** (`usePromotedPreviews.ts` — 3-line fix):
- Add `nodePreviewImages[locatorId]` as a second reactive dependency
alongside `nodeOutputs[locatorId]`
- Guard now passes when *either* source has data, not just `nodeOutputs`

**Tests** (`usePromotedPreviews.test.ts`):
- Add `nodePreviewImages` to mock store type and factory
- Add `seedPreviewImages()` helper
- Add `getNodeImageUrls.mockReset()` in `beforeEach` for proper test
isolation
- Two new test cases:
- `returns preview when only nodePreviewImages exist (e.g. GLSL live
preview)`
- `recomputes when preview images are populated after first evaluation`
- Clean up existing tests to use hoisted `getNodeImageUrls` mock
directly instead of `vi.mocked(useNodeOutputStore().getNodeImageUrls)`

## What this supersedes

This is a minimal re-implementation of #9461. That PR also modified
`promotionStore.ts` with a `_version`/`_touch()` monotonic counter to
manually force reactivity — that approach is dropped here as it is an
anti-pattern (manually managing reactivity counters instead of using
Vue's built-in reactivity system). The promotionStore changes were not
needed for this fix.

## Related

- Supersedes #9461
- Prerequisite work: #9198 (add GLSLShader to canvas image preview node
types)
- Upstream feature: #9201 (useGLSLPreview composable)
- Adjacent: #9435 (centralize node image rendering state in
NodeImageStore)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10165-fix-track-nodePreviewImages-in-usePromotedPreviews-for-GLSL-live-preview-propagation-3266d73d365081cd87d0d12c4c041907)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 12:54:21 -07:00
Alexander Brown
34a77e5016 test: harden subgraph test coverage and remove low-value tests (#9967)
## Summary

Harden subgraph test coverage: remove low-value change-detector tests,
consolidate fixtures, add behavioral coverage, and fix test
infrastructure issues. Includes minor production code corrections
discovered during test hardening.

## Changes

- **What**: Comprehensive subgraph test suite overhaul across 6 phases
  - Removed change-detector tests and redundant assertions
- Consolidated fixture helpers into `subgraphHelpers.ts` /
`subgraphFixtures.ts`
  - Added Pinia initialization and fixture reset to all test files
  - Fixed barrel import violations (circular dependency prevention)
  - Added behavioral coverage for slot connections, events, edge cases
  - Added E2E helper and smoke test for subgraph promotion
  - Exported `SubgraphSlotBase` from litegraph barrel for test access
- **Production code changes** (minor correctness fixes found during
testing):
- `resolveSubgraphInputLink.ts`: iterate forward (first-connected-wins)
to match `_resolveLinkedPromotionBySubgraphInput`
- `promotionSchema.ts`: return `[]` instead of throwing on invalid
`proxyWidgets`; console.warn always (not DEV-only)
  - `LGraph.ts`: disconnect-after-veto ordering fix
  - `litegraph.ts`: barrel export swap for `SubgraphSlotBase`
- **Stats**: 349 tests passing, 0 skipped across 26 test files

## Review Focus

- Tests that merely asserted default property values were deleted
(change detectors)
- Fixture state is now reset via `resetSubgraphFixtureState()` in
`beforeEach`
- All imports use `@/lib/litegraph/src/litegraph` barrel to avoid
circular deps
- Production changes are small and directly motivated by test findings

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-17 19:03:18 +00:00
Alexander Brown
b696b2f2e1 feat: migrate TS lint rules from ESLint to oxlint (#10114)
## Summary

Migrate two TypeScript lint rules from ESLint to oxlint and upgrade
`eslint-plugin-oxlint` to 1.55.0.

## Changes

- **Rule migration**: Move `typescript/no-import-type-side-effects` and
`typescript/no-empty-object-type` (with `allowInterfaces: 'always'`)
from `eslint.config.ts` to `.oxlintrc.json`.
- **Plugin upgrade**: Bump `eslint-plugin-oxlint` from 1.25.0 to 1.55.0,
which auto-disables additional ESLint rules now covered by oxlint. This
surfaced pre-existing `no-unused-expressions` violations (the `void`
prefixes and ternary-to-if/else changes), which are fixed in a separate
commit.
- **Dependencies**: None — `eslint-plugin-oxlint` auto-disables the
corresponding ESLint rules when they appear in `.oxlintrc.json`.

## Review Focus

- Verify the two migrated rules produce identical diagnostics in oxlint
as they did in ESLint

## Migration Status

See `temp/plans/eslint-to-oxlint-migration.md` for what can/can't
migrate and why.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 11:38:48 -07:00
jaeone94
15442e7ff8 fix: prevent nested SubgraphNode input slots from doubling on reload (#10187)
## Summary

- Fix nested SubgraphNode input slots doubling on each page reload
- Root cause: during configure, `_configureSubgraph` recreates
`SubgraphInput` objects with new references, and the `input-added` event
handler used `===` identity check which failed for these new objects,
causing `addInput()` to duplicate inputs
- Add `id`-based fallback matching in the `input-added` handler and
rebind `_subgraphSlot` with re-registered listeners

## Changes

**`SubgraphNode.ts:614-622`**: Add UUID `id` fallback to the `===`
reference check in the `input-added` event handler. When a stale
reference is matched by id, call `_addSubgraphInputListeners()` to
update `_subgraphSlot` and re-register listeners on the new
`SubgraphInput` object.

**`SubgraphNode.test.ts`**: 2 regression tests for nested subgraph
reconfigure scenarios.

## Test plan

- [x] Existing SubgraphNode tests pass (6 passed, 34 skipped)
- [x] New tests verify inputs don't duplicate after single and repeated
reconfigure cycles
- [x] Manual: create a subgraph containing another subgraph node, save,
reload — input slots should remain unchanged

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10187-fix-prevent-nested-SubgraphNode-input-slots-from-doubling-on-reload-3266d73d3650817286abea52365a626e)
by [Unito](https://www.unito.io)
2026-03-18 03:23:05 +09:00
jaeone94
6a8e6ad254 test: update E2E to expect missing nodes overlay on tab switch (#10190)
## Summary

- Update the E2E test expectation for missing nodes overlay behavior
when switching between opened workflows
- `showMissingNodes` was intentionally changed to `true` in
`workflowService.ts` so users always see missing node pack info when
switching tabs, allowing them to install missing packs without a page
reload
- The test previously asserted the overlay should NOT reappear; now it
asserts the overlay SHOULD reappear

## Test plan

- [x] CI E2E tests pass with updated expectation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10190-test-update-E2E-to-expect-missing-nodes-overlay-on-tab-switch-3266d73d36508106a48efff7309bd4e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 10:46:19 -07:00
Christian Byrne
a405a992af ci: re generate outdated test snapshot baseline (#10189)
Empty commit to trigger CI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10189-ci-re-generate-outdated-test-snapshot-baseline-3266d73d365081dfa053e30bdd04647a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 10:05:23 -07:00
Christian Byrne
2af3940867 feat: add trend visualization with sparklines to perf report (#9939)
## Summary

Add historical trend visualization (ASCII sparklines + directional
arrows) to the performance PR report, showing how each metric has moved
over recent commits on main.

## Changes

- **What**: New `sparkline()`, `trendDirection()`, `trendArrow()`
functions in `perf-stats.ts`. New collapsible "Trend" section in the
perf report showing per-metric sparklines, direction indicators, and
latest values. CI workflow updated to download historical data from the
`perf-data` orphan branch and switched to `setup-frontend` action with
`pnpm exec tsx`.

## Review Focus

- The trend section only renders when ≥3 historical data points exist
(gracefully absent otherwise)
- `trendDirection()` uses a split-half mean comparison with ±10%
threshold — review whether this sensitivity is appropriate
- The `git archive` step in `pr-perf-report.yaml` is idempotent and
fails silently if no perf-history data exists yet on the perf-data
branch

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9939-feat-add-trend-visualization-with-sparklines-to-perf-report-3246d73d36508125a6fcc39612f850fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:10:30 -07:00
Christian Byrne
48ae70159f feat: migrate 13 priority events from Mixpanel-only to GA4 via GTM (#9770)
## Summary

Migrate all client-side telemetry events to GA4 via the
GtmTelemetryProvider, completing the dual-write setup alongside
Mixpanel.

## Changes

- Add all client-side Mixpanel events to GtmTelemetryProvider for GA4
dual-write
- Map events to GA4 recommended names where applicable (search,
select_item, login, sign_up)
- Exclude server-side payment events (subscription success/cancel,
credit topup succeeded) - those belong in comfy-api via Measurement
Protocol
- Truncate error strings to 100 chars to respect GA4 parameter limits
- Keep event parameter counts under GA4 25-param limit

## Events Added

### GA4 Recommended Events
- sign_up / login (auth flow)
- begin_checkout (checkout flow)
- search (node search)
- select_item (node search result selected)
- select_content (template opened)
- view_promotion / select_promotion (subscription modal)

### Custom Events
- Survey, email verification, workflow lifecycle
(opened/imported/created/saved)
- Execution lifecycle (start/error/success)
- Template library, help center, settings, UI interactions
- App mode, share flow, page visibility, tab count

### Excluded (Server-Side Only)
- subscription_success -> comfy-api Stripe webhook
- subscription_cancel -> comfy-api Stripe webhook
- credit_topup_succeeded -> comfy-api Stripe webhook

## Testing
- Unit tests cover initialization, event dispatch, GA4 event name
mapping, and parameter handling
- Verified typecheck and lint pass

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-17 07:03:57 -07:00
Jukka Seppänen
0030cadba3 feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10111)
## Summary

Enable virtual nodes (e.g. Set/Get) to resolve their output source
directly when the source lives in a different subgraph.

## Changes

- **What**: Added optional resolveVirtualOutput method on LGraphNode and
a new resolution path in ExecutableNodeDTO.resolveOutput that checks it
before falling through to the existing getInputLink path. Includes unit
tests for the three code paths (happy path, missing DTO, fallthrough).

## Review Focus

- Fully backwards compatible — no existing node implements
resolveVirtualOutput, so the new path is always skipped for current
virtual nodes (Reroute, PrimitiveNode, etc.).

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots

Simple example in actual use, combined with new changes in KJNodes
allows using Get nodes inside subgraphs:

<img width="2242" height="1434" alt="image"
src="https://github.com/user-attachments/assets/cc940a95-e0bb-4adf-91b6-9adc43a74aa2"
/>

<img width="1436" height="440" alt="image"
src="https://github.com/user-attachments/assets/62044af5-0d6e-4c4e-b34c-d33e85f2b969"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10111-feat-resolveVirtualOutput-for-cross-subgraph-virtual-nodes-eg-Set-Get-3256d73d3650816a9f20e28029561c58)
by [Unito](https://www.unito.io)
2026-03-17 06:58:40 -07:00
Christian Byrne
60e8da308f fix: resolve nodes in subgraphs for image copy/paste and display (#10009)
## Summary

Replace `app.rootGraph.getNodeById()` with `resolveNode()` so node
lookups search subgraphs, fixing broken image copy/paste and display for
nodes inside subgraphs.

## Changes

- **What**: Updated 7 call sites across 5 files to use `resolveNode()`
from `litegraphUtil.ts` instead of `app.rootGraph.getNodeById()`. The
`resolveNode` function already existed and searches both the root graph
and all subgraphs. Added a unit test verifying subgraph node resolution
in `nodeOutputStore`.

## Review Focus

The fix is mechanical — each call site simply swaps
`app.rootGraph?.getNodeById(id)` for `resolveNode(id)`. The
`resolveNode` utility iterates `graph.subgraphs` if the node is not
found in the root graph.

Fixes #9993

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10009-fix-resolve-nodes-in-subgraphs-for-image-copy-paste-and-display-3256d73d3650814f9467c53999f5d755)
by [Unito](https://www.unito.io)
2026-03-17 06:58:01 -07:00
Christian Byrne
1ee75332b3 fix(vueNodes): decrease default size of reroute nodes (#8734)
## Summary

Remove the 225px minimum width constraint from reroute nodes so they
render at their intended ~75×26px size.

## Changes

- **What**: Reroute nodes now bypass the `min-w-[225px]` CSS constraint
and bottom padding applied to regular nodes. `resizable: false` is set
on the RerouteNode constructor to hide the resize handle. An
`isRerouteNode` computed in `LGraphNode.vue` gates these behaviors by
checking `nodeData.type === "Reroute"`.

## Review Focus

- Detection mechanism uses `type === "Reroute"` (explicit) rather than
`titleMode === NO_TITLE` (semantic but too broad). See PR #8574 as prior
art for reroute-specific conditionals.
- PR #7993 (open) modifies `LGraphNode.ts` pos/size setters but does not
touch `LGraphNode.vue` template or resize callback — no conflict
expected.

Fixes #4704

## Screenshots (if applicable)

Reroute nodes now render at ~75px wide instead of being forced to 225px
minimum.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8734-fix-vueNodes-decrease-default-size-of-reroute-nodes-3016d73d3650816dbeead93517a52f25)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-17 06:27:13 -07:00
112 changed files with 4621 additions and 2453 deletions

View File

@@ -173,6 +173,19 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download perf history from perf-data branch
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
continue-on-error: true
run: |
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
fi
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >

View File

@@ -97,6 +97,13 @@
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string

View File

@@ -0,0 +1,70 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
async function runAndOpenGallery(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
// Wait for SaveImage node to produce output
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30_000
})
// Open Assets sidebar tab and wait for it to load
await comfyPage.page.locator('.assets-tab-button').click()
await comfyPage.page
.locator('.sidebar-content-container')
.waitFor({ state: 'visible' })
// Wait for any asset card to appear (may contain img or video)
const assetCard = comfyPage.page
.locator('[role="button"]')
.filter({ has: comfyPage.page.locator('img, video') })
.first()
await expect(assetCard).toBeVisible({ timeout: 30_000 })
// Hover to reveal zoom button, then click it
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
})
})

View File

@@ -232,7 +232,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Does not report warning when switching between opened workflows', async ({
test('Reports missing nodes warning again when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -254,10 +254,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should not reappear
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

View File

@@ -631,6 +631,29 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -0,0 +1,160 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
getPromotedWidgetSnapshot,
getPromotedWidgets
} from '../helpers/promotedWidgets'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
test('hydrates legacy proxyWidgets deterministically across reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
expect(
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
).toBe(true)
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
})
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const projection = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById('11')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected host subgraph node 11')
const beforeType = hostNode.widgets?.[0]?.type
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
? hostNode.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
const firstPromotion = proxyWidgets[0]
if (!firstPromotion)
throw new Error('Expected at least one promoted widget entry')
const [sourceNodeId, sourceWidgetName] = firstPromotion
const subgraph = graph.subgraphs.get(hostNode.type)
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
if (!sourceNode?.widgets)
throw new Error('Expected promoted source node widget list')
sourceNode.widgets = sourceNode.widgets.filter(
(widget) => widget.name !== sourceWidgetName
)
return {
beforeType,
afterType: hostNode.widgets?.[0]?.type
}
})
expect(projection.beforeType).toBe('customtext')
expect(projection.afterType).toBe('button')
})
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
const cleanupResult = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const invalidPseudoEntries = () => {
const invalid: string[] = []
for (const node of graph.nodes) {
if (
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
)
continue
const subgraph = graph.subgraphs.get(node.type)
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
for (const entry of proxyWidgets) {
if (entry[1] !== '$$canvas-image-preview') continue
const sourceNodeId = Number(entry[0])
const sourceNode = subgraph?.getNodeById(sourceNodeId)
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
}
}
return invalid
}
const before = invalidPseudoEntries()
const hostNode = graph.getNodeById('7')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected preview host subgraph node 7')
;(
graph as unknown as { unpackSubgraph: (node: unknown) => void }
).unpackSubgraph(hostNode)
return {
before,
after: invalidPseudoEntries(),
hasNode7: Boolean(graph.getNodeById('7')),
hasNode8: Boolean(graph.getNodeById('8'))
}
})
expect(cleanupResult.before).toEqual([])
expect(cleanupResult.after).toEqual([])
expect(cleanupResult.hasNode7).toBe(false)
expect(cleanupResult.hasNode8).toBe(true)
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
})
})

View File

@@ -0,0 +1,24 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.workflow.loadWorkflow('links/single_connected_reroute_node')
await comfyPage.vueNodes.waitForNodes()
})
test(
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-reroute-node-compact.png'
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,71 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,13 +8,15 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
## Creating a New ADR

View File

@@ -152,13 +152,6 @@ export default defineConfig([
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'always'
}
],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',

410
pnpm-lock.yaml generated
View File

@@ -193,8 +193,8 @@ catalogs:
specifier: ^4.16.1
version: 4.16.1
eslint-plugin-oxlint:
specifier: 1.25.0
version: 1.25.0
specifier: 1.55.0
version: 1.55.0
eslint-plugin-storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -250,14 +250,14 @@ catalogs:
specifier: 22.5.2
version: 22.5.2
oxfmt:
specifier: ^0.34.0
version: 0.34.0
specifier: ^0.40.0
version: 0.40.0
oxlint:
specifier: ^1.49.0
version: 1.49.0
specifier: ^1.55.0
version: 1.55.0
oxlint-tsgolint:
specifier: ^0.14.2
version: 0.14.2
specifier: ^0.17.0
version: 0.17.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -659,13 +659,13 @@ importers:
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.25.0
version: 1.55.0
eslint-plugin-storybook:
specifier: 'catalog:'
version: 10.2.10(eslint@9.39.1(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -713,13 +713,13 @@ importers:
version: 22.5.2
oxfmt:
specifier: 'catalog:'
version: 0.34.0
version: 0.40.0
oxlint:
specifier: 'catalog:'
version: 1.49.0(oxlint-tsgolint@0.14.2)
version: 1.55.0(oxlint-tsgolint@0.17.0)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.14.2
version: 0.17.0
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2751,276 +2751,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.34.0':
resolution: {integrity: sha512-sqkqjh/Z38l+duOb1HtVqJTAj1grt2ttkobCopC/72+a4Xxz4xUgZPFyQ4HxrYMvyqO/YA0tvM1QbfOu70Gk1Q==}
'@oxfmt/binding-android-arm-eabi@0.40.0':
resolution: {integrity: sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.34.0':
resolution: {integrity: sha512-1KRCtasHcVcGOMwfOP9d5Bus2NFsN8yAYM5cBwi8LBg5UtXC3C49WHKrlEa8iF1BjOS6CR2qIqiFbGoA0DJQNQ==}
'@oxfmt/binding-android-arm64@0.40.0':
resolution: {integrity: sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.34.0':
resolution: {integrity: sha512-b+Rmw9Bva6e/7PBES2wLO8sEU7Mi0+/Kv+pXSe/Y8i4fWNftZZlGwp8P01eECaUqpXATfSgNxdEKy7+ssVNz7g==}
'@oxfmt/binding-darwin-arm64@0.40.0':
resolution: {integrity: sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.34.0':
resolution: {integrity: sha512-QGjpevWzf1T9COEokZEWt80kPOtthW1zhRbo7x4Qoz646eTTfi6XsHG2uHeDWJmTbgBoJZPMgj2TAEV/ppEZaA==}
'@oxfmt/binding-darwin-x64@0.40.0':
resolution: {integrity: sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.34.0':
resolution: {integrity: sha512-VMSaC02cG75qL59M9M/szEaqq/RsLfgpzQ4nqUu8BUnX1zkiZIW2gTpUv3ZJ6qpWnHxIlAXiRZjQwmcwpvtbcg==}
'@oxfmt/binding-freebsd-x64@0.40.0':
resolution: {integrity: sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.34.0':
resolution: {integrity: sha512-Klm367PFJhH6vYK3vdIOxFepSJZHPaBfIuqwxdkOcfSQ4qqc/M8sgK0UTFnJWWTA/IkhMIh1kW6uEqiZ/xtQqg==}
'@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
resolution: {integrity: sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.34.0':
resolution: {integrity: sha512-nqn0QueVXRfbN9m58/E9Zij0Ap8lzayx591eWBYn0sZrGzY1IRv9RYS7J/1YUXbb0Ugedo0a8qIWzUHU9bWQuA==}
'@oxfmt/binding-linux-arm-musleabihf@0.40.0':
resolution: {integrity: sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.34.0':
resolution: {integrity: sha512-DDn+dcqW+sMTCEjvLoQvC/VWJjG7h8wcdN/J+g7ZTdf/3/Dx730pQElxPPGsCXPhprb11OsPyMp5FwXjMY3qvA==}
'@oxfmt/binding-linux-arm64-gnu@0.40.0':
resolution: {integrity: sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.34.0':
resolution: {integrity: sha512-H+F8+71gHQoGTFPPJ6z4dD0Fzfzi0UP8Zx94h5kUmIFThLvMq5K1Y/bUUubiXwwHfwb5C3MPjUpYijiy0rj51Q==}
'@oxfmt/binding-linux-arm64-musl@0.40.0':
resolution: {integrity: sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.34.0':
resolution: {integrity: sha512-dIGnzTNhCXqQD5pzBwduLg8pClm+t8R53qaE9i5h8iua1iaFAJyLffh4847CNZSlASb7gn1Ofuv7KoG/EpoGZg==}
'@oxfmt/binding-linux-ppc64-gnu@0.40.0':
resolution: {integrity: sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.34.0':
resolution: {integrity: sha512-FGQ2GTTooilDte/ogwWwkHuuL3lGtcE3uKM2EcC7kOXNWdUfMY6Jx3JCodNVVbFoybv4A+HuCj8WJji2uu1Ceg==}
'@oxfmt/binding-linux-riscv64-gnu@0.40.0':
resolution: {integrity: sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.34.0':
resolution: {integrity: sha512-2dGbGneJ7ptOIVKMwEIHdCkdZEomh74X3ggo4hCzEXL/rl9HwfsZDR15MkqfQqAs6nVXMvtGIOMxjDYa5lwKaA==}
'@oxfmt/binding-linux-riscv64-musl@0.40.0':
resolution: {integrity: sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.34.0':
resolution: {integrity: sha512-cCtGgmrTrxq3OeSG0UAO+w6yLZTMeOF4XM9SAkNrRUxYhRQELSDQ/iNPCLyHhYNi38uHJQbS5RQweLUDpI4ajA==}
'@oxfmt/binding-linux-s390x-gnu@0.40.0':
resolution: {integrity: sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.34.0':
resolution: {integrity: sha512-7AvMzmeX+k7GdgitXp99GQoIV/QZIpAS7rwxQvC/T541yWC45nwvk4mpnU8N+V6dE5SPEObnqfhCjO80s7qIsg==}
'@oxfmt/binding-linux-x64-gnu@0.40.0':
resolution: {integrity: sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.34.0':
resolution: {integrity: sha512-uNiglhcmivJo1oDMh3hoN/Z0WsbEXOpRXZdQ3W/IkOpyV8WF308jFjSC1ZxajdcNRXWej0zgge9QXba58Owt+g==}
'@oxfmt/binding-linux-x64-musl@0.40.0':
resolution: {integrity: sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.34.0':
resolution: {integrity: sha512-5eFsTjCyji25j6zznzlMc+wQAZJoL9oWy576xhqd2efv+N4g1swIzuSDcb1dz4gpcVC6veWe9pAwD7HnrGjLwg==}
'@oxfmt/binding-openharmony-arm64@0.40.0':
resolution: {integrity: sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.34.0':
resolution: {integrity: sha512-6id8kK0t5hKfbV6LHDzRO21wRTA6ctTlKGTZIsG/mcoir0rssvaYsedUymF4HDj7tbCUlnxCX/qOajKlEuqbIw==}
'@oxfmt/binding-win32-arm64-msvc@0.40.0':
resolution: {integrity: sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.34.0':
resolution: {integrity: sha512-QHaz+w673mlYqn9v/+fuiKZpjkmagleXQ+NygShDv8tdHpRYX2oYhTJwwt9j1ZfVhRgza1EIUW3JmzCXmtPdhQ==}
'@oxfmt/binding-win32-ia32-msvc@0.40.0':
resolution: {integrity: sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.34.0':
resolution: {integrity: sha512-CXKQM/VaF+yuvGru8ktleHLJoBdjBtTFmAsLGePiESiTN0NjCI/PiaiOCfHMJ1HdP1LykvARUwMvgaN3tDhcrg==}
'@oxfmt/binding-win32-x64-msvc@0.40.0':
resolution: {integrity: sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.14.2':
resolution: {integrity: sha512-03WxIXguCXf1pTmoG2C6vqRcbrU9GaJCW6uTIiQdIQq4BrJnVWZv99KEUQQRkuHK78lOLa9g7B4K58NcVcB54g==}
'@oxlint-tsgolint/darwin-arm64@0.17.0':
resolution: {integrity: sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.14.2':
resolution: {integrity: sha512-ksMLl1cIWz3Jw+U79BhyCPdvohZcJ/xAKri5bpT6oeEM2GVnQCHBk/KZKlYrd7hZUTxz0sLnnKHE11XFnLASNQ==}
'@oxlint-tsgolint/darwin-x64@0.17.0':
resolution: {integrity: sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.14.2':
resolution: {integrity: sha512-2BgR535w7GLxBCyQD5DR3dBzbAgiBbG5QX1kAEVzOmWxJhhGxt5lsHdHebRo7ilukYLpBDkerz0mbMErblghCQ==}
'@oxlint-tsgolint/linux-arm64@0.17.0':
resolution: {integrity: sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.14.2':
resolution: {integrity: sha512-TUHFyVHfbbGtnTQZbUFgwvv3NzXBgzNLKdMUJw06thpiC7u5OW5qdk4yVXIC/xeVvdl3NAqTfcT4sA32aiMubg==}
'@oxlint-tsgolint/linux-x64@0.17.0':
resolution: {integrity: sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.14.2':
resolution: {integrity: sha512-OfYHa/irfVggIFEC4TbawsI7Hwrttppv//sO/e00tu4b2QRga7+VHAwtCkSFWSr0+BsO4InRYVA0+pun5BinpQ==}
'@oxlint-tsgolint/win32-arm64@0.17.0':
resolution: {integrity: sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.14.2':
resolution: {integrity: sha512-5gxwbWYE2pP+pzrO4SEeYvLk4N609eAe18rVXUx+en3qtHBkU8VM2jBmMcZdIHn+G05leu4pYvwAvw6tvT9VbA==}
'@oxlint-tsgolint/win32-x64@0.17.0':
resolution: {integrity: sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.49.0':
resolution: {integrity: sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg==}
'@oxlint/binding-android-arm-eabi@1.55.0':
resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.49.0':
resolution: {integrity: sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w==}
'@oxlint/binding-android-arm64@1.55.0':
resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.49.0':
resolution: {integrity: sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw==}
'@oxlint/binding-darwin-arm64@1.55.0':
resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.49.0':
resolution: {integrity: sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ==}
'@oxlint/binding-darwin-x64@1.55.0':
resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.49.0':
resolution: {integrity: sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA==}
'@oxlint/binding-freebsd-x64@1.55.0':
resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.49.0':
resolution: {integrity: sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA==}
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.49.0':
resolution: {integrity: sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ==}
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.49.0':
resolution: {integrity: sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg==}
'@oxlint/binding-linux-arm64-gnu@1.55.0':
resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.49.0':
resolution: {integrity: sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ==}
'@oxlint/binding-linux-arm64-musl@1.55.0':
resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.49.0':
resolution: {integrity: sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ==}
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.49.0':
resolution: {integrity: sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg==}
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.49.0':
resolution: {integrity: sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ==}
'@oxlint/binding-linux-riscv64-musl@1.55.0':
resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.49.0':
resolution: {integrity: sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ==}
'@oxlint/binding-linux-s390x-gnu@1.55.0':
resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.49.0':
resolution: {integrity: sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ==}
'@oxlint/binding-linux-x64-gnu@1.55.0':
resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.49.0':
resolution: {integrity: sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg==}
'@oxlint/binding-linux-x64-musl@1.55.0':
resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.49.0':
resolution: {integrity: sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA==}
'@oxlint/binding-openharmony-arm64@1.55.0':
resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.49.0':
resolution: {integrity: sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA==}
'@oxlint/binding-win32-arm64-msvc@1.55.0':
resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.49.0':
resolution: {integrity: sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug==}
'@oxlint/binding-win32-ia32-msvc@1.55.0':
resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.49.0':
resolution: {integrity: sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ==}
'@oxlint/binding-win32-x64-msvc@1.55.0':
resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -5556,8 +5556,8 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-oxlint@1.25.0:
resolution: {integrity: sha512-grS4KdR9FAxoQC+wMkepeQHL4osMhoYfUI11Pot6Gitqr4wWi+JZrX0Shr8Bs9fjdWhEjtaZIV6cr4mbfytmyw==}
eslint-plugin-oxlint@1.55.0:
resolution: {integrity: sha512-5ng7DOuikSE64e7hX2HBqEWdmql+Q4FWppBoBkxKKflLt1j9LXhab5BN3bYJKyrAihuK1/VH2JvfNefeOZAqpA==}
eslint-plugin-storybook@10.2.10:
resolution: {integrity: sha512-aWkoh2rhTaEsMA4yB1iVIcISM5wb0uffp09ZqhwpoD4GAngCs131uq6un+QdnOMc7vXyAnBBfsuhtOj8WwCUgw==}
@@ -7243,21 +7243,21 @@ packages:
oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
oxfmt@0.34.0:
resolution: {integrity: sha512-t+zTE4XGpzPTK+Zk9gSwcJcFi4pqjl6PwO/ZxPBJiJQ2XCKMucwjPlHxvPHyVKJtkMSyrDGfQ7Ntg/hUr4OgHQ==}
oxfmt@0.40.0:
resolution: {integrity: sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.14.2:
resolution: {integrity: sha512-XJsFIQwnYJgXFlNDz2MncQMWYxwnfy4BCy73mdiFN/P13gEZrAfBU4Jmz2XXFf9UG0wPILdi7hYa6t0KmKQLhw==}
oxlint-tsgolint@0.17.0:
resolution: {integrity: sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==}
hasBin: true
oxlint@1.49.0:
resolution: {integrity: sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ==}
oxlint@1.55.0:
resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.14.1'
oxlint-tsgolint: '>=0.15.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
@@ -11191,136 +11191,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.15.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.34.0':
'@oxfmt/binding-android-arm-eabi@0.40.0':
optional: true
'@oxfmt/binding-android-arm64@0.34.0':
'@oxfmt/binding-android-arm64@0.40.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.34.0':
'@oxfmt/binding-darwin-arm64@0.40.0':
optional: true
'@oxfmt/binding-darwin-x64@0.34.0':
'@oxfmt/binding-darwin-x64@0.40.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.34.0':
'@oxfmt/binding-freebsd-x64@0.40.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.34.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.40.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.34.0':
'@oxfmt/binding-linux-arm-musleabihf@0.40.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.34.0':
'@oxfmt/binding-linux-arm64-gnu@0.40.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.34.0':
'@oxfmt/binding-linux-arm64-musl@0.40.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.34.0':
'@oxfmt/binding-linux-ppc64-gnu@0.40.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.34.0':
'@oxfmt/binding-linux-riscv64-gnu@0.40.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.34.0':
'@oxfmt/binding-linux-riscv64-musl@0.40.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.34.0':
'@oxfmt/binding-linux-s390x-gnu@0.40.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.34.0':
'@oxfmt/binding-linux-x64-gnu@0.40.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.34.0':
'@oxfmt/binding-linux-x64-musl@0.40.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.34.0':
'@oxfmt/binding-openharmony-arm64@0.40.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.34.0':
'@oxfmt/binding-win32-arm64-msvc@0.40.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.34.0':
'@oxfmt/binding-win32-ia32-msvc@0.40.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.34.0':
'@oxfmt/binding-win32-x64-msvc@0.40.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.14.2':
'@oxlint-tsgolint/darwin-arm64@0.17.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.14.2':
'@oxlint-tsgolint/darwin-x64@0.17.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.14.2':
'@oxlint-tsgolint/linux-arm64@0.17.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.14.2':
'@oxlint-tsgolint/linux-x64@0.17.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.14.2':
'@oxlint-tsgolint/win32-arm64@0.17.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.14.2':
'@oxlint-tsgolint/win32-x64@0.17.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.49.0':
'@oxlint/binding-android-arm-eabi@1.55.0':
optional: true
'@oxlint/binding-android-arm64@1.49.0':
'@oxlint/binding-android-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-arm64@1.49.0':
'@oxlint/binding-darwin-arm64@1.55.0':
optional: true
'@oxlint/binding-darwin-x64@1.49.0':
'@oxlint/binding-darwin-x64@1.55.0':
optional: true
'@oxlint/binding-freebsd-x64@1.49.0':
'@oxlint/binding-freebsd-x64@1.55.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.49.0':
'@oxlint/binding-linux-arm-gnueabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.49.0':
'@oxlint/binding-linux-arm-musleabihf@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.49.0':
'@oxlint/binding-linux-arm64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.49.0':
'@oxlint/binding-linux-arm64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.49.0':
'@oxlint/binding-linux-ppc64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.49.0':
'@oxlint/binding-linux-riscv64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.49.0':
'@oxlint/binding-linux-riscv64-musl@1.55.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.49.0':
'@oxlint/binding-linux-s390x-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.49.0':
'@oxlint/binding-linux-x64-gnu@1.55.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.49.0':
'@oxlint/binding-linux-x64-musl@1.55.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.49.0':
'@oxlint/binding-openharmony-arm64@1.55.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.49.0':
'@oxlint/binding-win32-arm64-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.49.0':
'@oxlint/binding-win32-ia32-msvc@1.55.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.49.0':
'@oxlint/binding-win32-x64-msvc@1.55.0':
optional: true
'@phenomnomnominal/tsquery@6.1.4(typescript@5.9.3)':
@@ -14007,7 +14007,7 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -14020,7 +14020,7 @@ snapshots:
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
oxlint: 1.55.0(oxlint-tsgolint@0.17.0)
transitivePeerDependencies:
- typescript
@@ -14072,7 +14072,7 @@ snapshots:
- supports-color
optional: true
eslint-plugin-oxlint@1.25.0:
eslint-plugin-oxlint@1.55.0:
dependencies:
jsonc-parser: 3.3.1
@@ -16086,61 +16086,61 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
oxfmt@0.34.0:
oxfmt@0.40.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.34.0
'@oxfmt/binding-android-arm64': 0.34.0
'@oxfmt/binding-darwin-arm64': 0.34.0
'@oxfmt/binding-darwin-x64': 0.34.0
'@oxfmt/binding-freebsd-x64': 0.34.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.34.0
'@oxfmt/binding-linux-arm-musleabihf': 0.34.0
'@oxfmt/binding-linux-arm64-gnu': 0.34.0
'@oxfmt/binding-linux-arm64-musl': 0.34.0
'@oxfmt/binding-linux-ppc64-gnu': 0.34.0
'@oxfmt/binding-linux-riscv64-gnu': 0.34.0
'@oxfmt/binding-linux-riscv64-musl': 0.34.0
'@oxfmt/binding-linux-s390x-gnu': 0.34.0
'@oxfmt/binding-linux-x64-gnu': 0.34.0
'@oxfmt/binding-linux-x64-musl': 0.34.0
'@oxfmt/binding-openharmony-arm64': 0.34.0
'@oxfmt/binding-win32-arm64-msvc': 0.34.0
'@oxfmt/binding-win32-ia32-msvc': 0.34.0
'@oxfmt/binding-win32-x64-msvc': 0.34.0
'@oxfmt/binding-android-arm-eabi': 0.40.0
'@oxfmt/binding-android-arm64': 0.40.0
'@oxfmt/binding-darwin-arm64': 0.40.0
'@oxfmt/binding-darwin-x64': 0.40.0
'@oxfmt/binding-freebsd-x64': 0.40.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.40.0
'@oxfmt/binding-linux-arm-musleabihf': 0.40.0
'@oxfmt/binding-linux-arm64-gnu': 0.40.0
'@oxfmt/binding-linux-arm64-musl': 0.40.0
'@oxfmt/binding-linux-ppc64-gnu': 0.40.0
'@oxfmt/binding-linux-riscv64-gnu': 0.40.0
'@oxfmt/binding-linux-riscv64-musl': 0.40.0
'@oxfmt/binding-linux-s390x-gnu': 0.40.0
'@oxfmt/binding-linux-x64-gnu': 0.40.0
'@oxfmt/binding-linux-x64-musl': 0.40.0
'@oxfmt/binding-openharmony-arm64': 0.40.0
'@oxfmt/binding-win32-arm64-msvc': 0.40.0
'@oxfmt/binding-win32-ia32-msvc': 0.40.0
'@oxfmt/binding-win32-x64-msvc': 0.40.0
oxlint-tsgolint@0.14.2:
oxlint-tsgolint@0.17.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.14.2
'@oxlint-tsgolint/darwin-x64': 0.14.2
'@oxlint-tsgolint/linux-arm64': 0.14.2
'@oxlint-tsgolint/linux-x64': 0.14.2
'@oxlint-tsgolint/win32-arm64': 0.14.2
'@oxlint-tsgolint/win32-x64': 0.14.2
'@oxlint-tsgolint/darwin-arm64': 0.17.0
'@oxlint-tsgolint/darwin-x64': 0.17.0
'@oxlint-tsgolint/linux-arm64': 0.17.0
'@oxlint-tsgolint/linux-x64': 0.17.0
'@oxlint-tsgolint/win32-arm64': 0.17.0
'@oxlint-tsgolint/win32-x64': 0.17.0
oxlint@1.49.0(oxlint-tsgolint@0.14.2):
oxlint@1.55.0(oxlint-tsgolint@0.17.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.49.0
'@oxlint/binding-android-arm64': 1.49.0
'@oxlint/binding-darwin-arm64': 1.49.0
'@oxlint/binding-darwin-x64': 1.49.0
'@oxlint/binding-freebsd-x64': 1.49.0
'@oxlint/binding-linux-arm-gnueabihf': 1.49.0
'@oxlint/binding-linux-arm-musleabihf': 1.49.0
'@oxlint/binding-linux-arm64-gnu': 1.49.0
'@oxlint/binding-linux-arm64-musl': 1.49.0
'@oxlint/binding-linux-ppc64-gnu': 1.49.0
'@oxlint/binding-linux-riscv64-gnu': 1.49.0
'@oxlint/binding-linux-riscv64-musl': 1.49.0
'@oxlint/binding-linux-s390x-gnu': 1.49.0
'@oxlint/binding-linux-x64-gnu': 1.49.0
'@oxlint/binding-linux-x64-musl': 1.49.0
'@oxlint/binding-openharmony-arm64': 1.49.0
'@oxlint/binding-win32-arm64-msvc': 1.49.0
'@oxlint/binding-win32-ia32-msvc': 1.49.0
'@oxlint/binding-win32-x64-msvc': 1.49.0
oxlint-tsgolint: 0.14.2
'@oxlint/binding-android-arm-eabi': 1.55.0
'@oxlint/binding-android-arm64': 1.55.0
'@oxlint/binding-darwin-arm64': 1.55.0
'@oxlint/binding-darwin-x64': 1.55.0
'@oxlint/binding-freebsd-x64': 1.55.0
'@oxlint/binding-linux-arm-gnueabihf': 1.55.0
'@oxlint/binding-linux-arm-musleabihf': 1.55.0
'@oxlint/binding-linux-arm64-gnu': 1.55.0
'@oxlint/binding-linux-arm64-musl': 1.55.0
'@oxlint/binding-linux-ppc64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-gnu': 1.55.0
'@oxlint/binding-linux-riscv64-musl': 1.55.0
'@oxlint/binding-linux-s390x-gnu': 1.55.0
'@oxlint/binding-linux-x64-gnu': 1.55.0
'@oxlint/binding-linux-x64-musl': 1.55.0
'@oxlint/binding-openharmony-arm64': 1.55.0
'@oxlint/binding-win32-arm64-msvc': 1.55.0
'@oxlint/binding-win32-ia32-msvc': 1.55.0
'@oxlint/binding-win32-x64-msvc': 1.55.0
oxlint-tsgolint: 0.17.0
p-limit@3.1.0:
dependencies:

View File

@@ -65,7 +65,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-oxlint: 1.55.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
@@ -84,9 +84,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.5.2
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -7,6 +7,9 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -73,8 +76,11 @@ function groupByName(
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
for (const entry of readdirSync(HISTORY_DIR)) {
const entryPath = join(HISTORY_DIR, entry)
const filePath = entry.endsWith('.json')
? entryPath
: join(entryPath, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
@@ -102,6 +108,27 @@ function getHistoricalStats(
return computeStats(values)
}
function getHistoricalTimeSeries(
reports: PerfReport[],
testName: string,
metric: MetricKey
): number[] {
const sorted = [...reports].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
const values: number[] = []
for (const r of sorted) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
values.push(
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
)
}
}
return values
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
@@ -233,6 +260,34 @@ function renderFullReport(
}
lines.push('', '</details>')
const trendRows: string[] = []
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const series = getHistoricalTimeSeries(historical, testName, key)
if (series.length < 3) continue
const dir = trendDirection(series)
const arrow = trendArrow(dir)
const spark = sparkline(series)
const last = series[series.length - 1]
trendRows.push(
`| ${testName}: ${label} | ${spark} | ${arrow} | ${formatValue(last, unit)} |`
)
}
}
if (trendRows.length > 0) {
lines.push(
'',
`<details><summary>Trend (last ${historical.length} commits on main)</summary>`,
'',
'| Metric | Trend | Dir | Latest |',
'|--------|-------|-----|--------|',
...trendRows,
'',
'</details>'
)
}
return lines
}

View File

@@ -5,6 +5,9 @@ import {
computeStats,
formatSignificance,
isNoteworthy,
sparkline,
trendArrow,
trendDirection,
zScore
} from './perf-stats'
@@ -131,3 +134,68 @@ describe('isNoteworthy', () => {
expect(isNoteworthy('noisy')).toBe(false)
})
})
describe('sparkline', () => {
it('returns empty string for no values', () => {
expect(sparkline([])).toBe('')
})
it('returns mid-height for single value', () => {
expect(sparkline([50])).toBe('▄')
})
it('renders ascending values low to high', () => {
const result = sparkline([0, 25, 50, 75, 100])
expect(result).toBe('▁▃▅▆█')
})
it('renders identical values as flat line', () => {
const result = sparkline([10, 10, 10])
expect(result).toBe('▄▄▄')
})
it('renders descending values high to low', () => {
const result = sparkline([100, 50, 0])
expect(result).toBe('█▅▁')
})
})
describe('trendDirection', () => {
it('returns stable for fewer than 3 values', () => {
expect(trendDirection([])).toBe('stable')
expect(trendDirection([1])).toBe('stable')
expect(trendDirection([1, 2])).toBe('stable')
})
it('detects rising trend', () => {
expect(trendDirection([10, 10, 10, 20, 20, 20])).toBe('rising')
})
it('detects falling trend', () => {
expect(trendDirection([20, 20, 20, 10, 10, 10])).toBe('falling')
})
it('returns stable for flat data', () => {
expect(trendDirection([100, 100, 100, 100])).toBe('stable')
})
it('returns stable for small fluctuations within 10%', () => {
expect(trendDirection([100, 100, 100, 105, 105, 105])).toBe('stable')
})
it('detects rising when baseline is zero but current is non-zero', () => {
expect(trendDirection([0, 0, 0, 5, 5, 5])).toBe('rising')
})
it('returns stable when both halves are zero', () => {
expect(trendDirection([0, 0, 0, 0, 0, 0])).toBe('stable')
})
})
describe('trendArrow', () => {
it('returns correct emoji for each direction', () => {
expect(trendArrow('rising')).toBe('📈')
expect(trendArrow('falling')).toBe('📉')
expect(trendArrow('stable')).toBe('➡️')
})
})

View File

@@ -61,3 +61,53 @@ export function formatSignificance(
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
export function sparkline(values: number[]): string {
if (values.length === 0) return ''
if (values.length === 1) return SPARK_CHARS[3]
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min
return values
.map((v) => {
if (range === 0) return SPARK_CHARS[3]
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1))
return SPARK_CHARS[idx]
})
.join('')
}
export type TrendDirection = 'rising' | 'falling' | 'stable'
export function trendDirection(values: number[]): TrendDirection {
if (values.length < 3) return 'stable'
const half = Math.floor(values.length / 2)
const firstHalf = values.slice(0, half)
const secondHalf = values.slice(-half)
const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
if (firstMean === 0) return secondMean > 0 ? 'rising' : 'stable'
const changePct = ((secondMean - firstMean) / firstMean) * 100
if (changePct > 10) return 'rising'
if (changePct < -10) return 'falling'
return 'stable'
}
export function trendArrow(dir: TrendDirection): string {
switch (dir) {
case 'rising':
return '📈'
case 'falling':
return '📉'
case 'stable':
return '➡️'
}
}

View File

@@ -1,5 +1,14 @@
@import '@comfyorg/design-system/css/style.css';
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,
.disable-animations *::before,
.disable-animations *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
@media (prefers-reduced-motion: no-preference) {
/* List transition animations */
.list-scale-move,

View File

@@ -10,6 +10,8 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -17,6 +19,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -38,6 +41,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
@@ -97,21 +101,27 @@ const mappedSelections = computed((): WidgetEntry[] => {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
}

View File

@@ -12,6 +12,7 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -23,6 +24,7 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
@@ -43,6 +45,16 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
@@ -52,7 +64,14 @@ function toggleLinearMode() {
const tooltipPt = {
root: {
style: { transform: 'translateX(calc(50% - 16px))' }
style: {
transform: 'translateX(calc(50% - 16px))',
whiteSpace: 'nowrap',
maxWidth: 'none'
}
},
text: {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
@@ -68,9 +87,7 @@ const tooltipPt = {
>
<Button
v-tooltip.bottom="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt

View File

@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -60,7 +60,7 @@ const mountComponent = (
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
MediaLightbox: true
},
directives: {
tooltip: () => {}

View File

@@ -45,7 +45,7 @@
</div>
</div>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'

View File

@@ -170,7 +170,7 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -58,7 +58,7 @@
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'

View File

@@ -0,0 +1,229 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
enableAutoUnmount(afterEach)
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import MediaLightbox from './MediaLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
gallery: 'Gallery',
previous: 'Previous',
next: 'Next'
}
}
}
})
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
isAudio?: boolean
}
describe('MediaLightbox', () => {
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
const mockResultAudio = {
name: 'ResultAudio',
template:
'<div class="mock-result-audio" data-testid="result-audio"></div>',
props: ['result']
}
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image2.jpg',
id: '2'
},
{
filename: 'image3.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '789' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
isAudio: false,
url: 'image3.jpg',
id: '3'
}
]
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(MediaLightbox, {
global: {
plugins: [i18n],
components: {
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo,
ResultAudio: mockResultAudio
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders overlay with role="dialog" and aria-modal', async () => {
const wrapper = mountGallery()
await nextTick()
const dialog = wrapper.find('[role="dialog"]')
expect(dialog.exists()).toBe(true)
expect(dialog.attributes('aria-modal')).toBe('true')
})
it('shows navigation buttons when multiple items', async () => {
const wrapper = mountGallery()
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(true)
})
it('hides navigation buttons for single item', async () => {
const wrapper = mountGallery({
allGalleryItems: [mockGalleryItems[0]] as ResultItemImpl[]
})
await nextTick()
expect(wrapper.find('[aria-label="Previous"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="Next"]').exists()).toBe(false)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
expect(wrapper.find('[data-mask]').exists()).toBe(false)
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
expect(wrapper.find('[data-mask]').exists()).toBe(true)
})
it('emits update:activeIndex with -1 when close button clicked', async () => {
const wrapper = mountGallery()
await nextTick()
await wrapper.find('[aria-label="Close"]').trigger('click')
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
describe('keyboard navigation', () => {
it('navigates to next item on ArrowRight', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
})
it('navigates to previous item on ArrowLeft', async () => {
const wrapper = mountGallery({ activeIndex: 1 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
})
it('wraps to last item on ArrowLeft from first', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
})
it('closes gallery on Escape', async () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
})
})

View File

@@ -0,0 +1,149 @@
<template>
<Teleport to="body">
<div
v-if="galleryVisible"
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-label="$t('g.gallery')"
tabindex="-1"
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/90 outline-none"
data-mask
@mousedown="onMaskMouseDown"
@mouseup="onMaskMouseUp"
@keydown.stop="handleKeyDown"
>
<!-- Close Button -->
<Button
variant="secondary"
size="icon-lg"
class="absolute top-4 right-4 z-10 rounded-full"
:aria-label="$t('g.close')"
@click="close"
>
<i class="icon-[lucide--x] size-5" />
</Button>
<!-- Previous Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 left-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.previous')"
@click="navigateImage(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</Button>
<!-- Content -->
<div class="flex max-h-full max-w-full items-center justify-center">
<template v-if="activeItem">
<ComfyImage
v-if="activeItem.isImage"
:key="activeItem.url"
:src="activeItem.url"
:contain="false"
:alt="activeItem.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
</template>
</div>
<!-- Next Button -->
<Button
v-if="hasMultiple"
variant="secondary"
size="icon-lg"
class="fixed top-1/2 right-4 z-10 -translate-y-1/2 rounded-full"
:aria-label="$t('g.next')"
@click="navigateImage(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</Button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const galleryVisible = ref(false)
const dialogRef = ref<HTMLElement>()
let previouslyFocusedElement: HTMLElement | null = null
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
const activeItem = computed(() => props.allGalleryItems[props.activeIndex])
watch(
() => props.activeIndex,
(index) => {
galleryVisible.value = index !== -1
if (index !== -1) {
previouslyFocusedElement = document.activeElement as HTMLElement | null
void nextTick(() => dialogRef.value?.focus())
}
},
{ immediate: true }
)
function close() {
galleryVisible.value = false
emit('update:activeIndex', -1)
previouslyFocusedElement?.focus()
previouslyFocusedElement = null
}
function navigateImage(direction: number) {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
let maskMouseDownTarget: EventTarget | null = null
function onMaskMouseDown(event: MouseEvent) {
maskMouseDownTarget = event.target
}
function onMaskMouseUp(event: MouseEvent) {
if (
maskMouseDownTarget === event.target &&
(event.target as HTMLElement)?.hasAttribute('data-mask')
) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
const actions: Record<string, () => void> = {
ArrowLeft: () => navigateImage(-1),
ArrowRight: () => navigateImage(1),
Escape: () => close()
}
const action = actions[event.key]
if (action) {
event.preventDefault()
action()
}
}
</script>

View File

@@ -1,184 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
}
describe('ResultGallery', () => {
// Mock ComfyImage and ResultVideo components
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
// Sample gallery items - using mock instances with only required properties
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image2.jpg',
id: '2'
}
]
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
// Create mock elements for Galleria to find
document.body.innerHTML = `
<div id="app"></div>
`
})
afterEach(() => {
// Clean up any elements added to body
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: {
Galleria,
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders Galleria component with correct props', async () => {
const wrapper = mountGallery()
await nextTick() // Wait for component to mount
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
expect(galleria.props('value')).toEqual(mockGalleryItems)
expect(galleria.props('showIndicators')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(true)
expect(galleria.props('fullScreen')).toBe(true)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
type GalleryVM = typeof wrapper.vm & {
galleryVisible: boolean
}
const vm = wrapper.vm as GalleryVM
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
// galleryVisible should become true
expect(vm.galleryVisible).toBe(true)
})
it('should render the component properly', () => {
// This is a meta-test to confirm the component mounts properly
const wrapper = mountGallery()
// We can't directly test the compiled CSS, but we can verify the component renders
expect(wrapper.exists()).toBe(true)
// Verify that the Galleria component exists and is properly mounted
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
})
it('ensures correct configuration for mobile viewport', async () => {
// Mock window.matchMedia to simulate mobile viewport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query.includes('max-width: 768px'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
const wrapper = mountGallery()
await nextTick()
// Verify mobile media query is working
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
// Check if the component renders with Galleria
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
interface GalleriaPT {
prevButton?: { style?: string }
nextButton?: { style?: string }
}
const pt = galleria.props('pt') as GalleriaPT
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})
// Additional tests for interaction could be added once we can reliably
// test Galleria component in fullscreen mode
})

View File

@@ -1,151 +0,0 @@
<template>
<Galleria
v-model:visible="galleryVisible"
:active-index="activeIndex"
:value="allGalleryItems"
:show-indicators="false"
change-item-on-indicator-hover
:show-item-navigators="hasMultiple"
full-screen
:circular="hasMultiple"
:show-thumbnails="false"
:pt="{
mask: {
onMousedown: onMaskMouseDown,
onMouseup: onMaskMouseUp,
'data-mask': true
},
prevButton: {
style: 'position: fixed !important'
},
nextButton: {
style: 'position: fixed !important'
}
}"
@update:visible="handleVisibilityChange"
@update:active-index="handleActiveIndexChange"
>
<template #item="{ item }">
<ComfyImage
v-if="item.isImage"
:key="item.url"
:src="item.url"
:contain="false"
:alt="item.filename"
class="size-auto max-h-[90vh] max-w-[90vw] object-contain"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
</template>
</Galleria>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
}>()
const props = defineProps<{
allGalleryItems: ResultItemImpl[]
activeIndex: number
}>()
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
let maskMouseDownTarget: EventTarget | null = null
const onMaskMouseDown = (event: MouseEvent) => {
maskMouseDownTarget = event.target
}
const onMaskMouseUp = (event: MouseEvent) => {
const maskEl = document.querySelector('[data-mask]')
if (
galleryVisible.value &&
maskMouseDownTarget === event.target &&
maskMouseDownTarget === maskEl
) {
galleryVisible.value = false
handleVisibilityChange(false)
}
}
watch(
() => props.activeIndex,
(index) => {
if (index !== -1) {
galleryVisible.value = true
}
}
)
const handleVisibilityChange = (visible: boolean) => {
if (!visible) {
emit('update:activeIndex', -1)
}
}
const handleActiveIndexChange = (index: number) => {
emit('update:activeIndex', index)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!galleryVisible.value) return
switch (event.key) {
case 'ArrowLeft':
navigateImage(-1)
break
case 'ArrowRight':
navigateImage(1)
break
case 'Escape':
galleryVisible.value = false
handleVisibilityChange(false)
break
}
}
const navigateImage = (direction: number) => {
const newIndex =
(props.activeIndex + direction + props.allGalleryItems.length) %
props.allGalleryItems.length
emit('update:activeIndex', newIndex)
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style>
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
cannot use scoped style here. */
.p-galleria-close-button {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: 1;
}
/* Mobile/tablet specific fixes */
@media screen and (max-width: 768px) {
.p-galleria-prev-button,
.p-galleria-next-button {
z-index: 2;
}
}
</style>

View File

@@ -28,8 +28,9 @@ export const buttonVariants = cva({
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0',
icon: 'size-8',
'icon-lg': 'size-10',
unset: ''
}
},
@@ -54,8 +55,13 @@ const variants = [
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']
>
const sizes = [
'sm',
'md',
'lg',
'icon-sm',
'icon',
'icon-lg'
] as const satisfies Array<ButtonVariants['size']>
export const FOR_STORIES = { variants, sizes } as const

View File

@@ -165,8 +165,7 @@ export function useMoreOptionsMenu() {
const menuOptions = computed((): MenuOption[] => {
// Reference selection flags to ensure re-computation when they change
optionsVersion.value
void optionsVersion.value
const states = computeSelectionFlags()
// Detect single group selection context (and no nodes explicitly selected)

View File

@@ -5,6 +5,19 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export function extractWidgetStringValue(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (
value &&
typeof value === 'object' &&
'filename' in value &&
typeof value.filename === 'string'
)
return value.filename
return undefined
}
// Private image utility functions
interface ImageLayerFilenames {
@@ -84,62 +97,23 @@ export function useMaskEditorLoader() {
let nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
const imageWidget = node.widgets?.find((w) => w.name === 'image')
const widgetFilename = imageWidget
? extractWidgetStringValue(imageWidget.value)
: undefined
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
if (widgetFilename && !widgetFilename.startsWith('$')) {
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
const parsed = parseImageWidgetValue(widgetFilename)
nodeImageRef = {
filename: parsed.filename,
type: parsed.type || 'input',
subfolder: parsed.subfolder || undefined
}
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
}
const fileToQuery = widgetFilename || nodeImageRef.filename

View File

@@ -0,0 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}

View File

@@ -1,16 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
/**
* Composable for handling canvas image previews in nodes

View File

@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'getNodeImageUrls'
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
@@ -35,6 +35,7 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
@@ -71,12 +72,24 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -119,7 +132,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
@@ -143,9 +156,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.webm'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
@@ -162,9 +173,7 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.mp3'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
@@ -194,13 +203,11 @@ describe(usePromotedPreviews, () => {
)
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -208,6 +215,58 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
@@ -253,7 +312,7 @@ describe(usePromotedPreviews, () => {
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)

View File

@@ -39,16 +39,18 @@ export function usePromotedPreviews(
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
if (!interiorNode) continue
// Read from the reactive nodeOutputs ref to establish Vue
// dependency tracking. getNodeImageUrls reads from the
// non-reactive app.nodeOutputs, so without this access the
// computed would never re-evaluate when outputs change.
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
)
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
if (!_reactiveOutputs?.images?.length) continue
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue

View File

@@ -264,7 +264,7 @@ describe('useJobList', () => {
const { jobItems } = initComposable()
await flush()
jobItems.value
void jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
@@ -275,7 +275,7 @@ describe('useJobList', () => {
await vi.advanceTimersByTimeAsync(3000)
await flush()
jobItems.value
void jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
@@ -292,7 +292,7 @@ describe('useJobList', () => {
const { jobItems } = initComposable()
await flush()
jobItems.value
void jobItems.value
queueStoreMock.pendingTasks = []
await flush()
@@ -303,7 +303,7 @@ describe('useJobList', () => {
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
]
await flush()
jobItems.value
void jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',

View File

@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
| 'top'
@@ -558,10 +558,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = resolveNode(nodeId) ?? null
}
updateImageUrl()

View File

@@ -5,7 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -515,7 +518,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d && api.getServerFeature('assets', false)) {
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(

View File

@@ -104,7 +104,13 @@ describe('useLoad3dViewer', () => {
}),
forceRender: vi.fn(),
remove: vi.fn(),
setTargetSize: vi.fn()
setTargetSize: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
setCameraState: vi.fn(),
addEventListener: vi.fn(),
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false)
}
mockSourceLoad3d = {
@@ -533,7 +539,9 @@ describe('useLoad3dViewer', () => {
describe('handleBackgroundImageUpdate', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -550,7 +558,9 @@ describe('useLoad3dViewer', () => {
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -595,6 +605,21 @@ describe('useLoad3dViewer', () => {
'toastMessages.failedToUploadBackgroundImage'
)
})
it('should work in standalone mode without a node', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
await viewer.initializeStandaloneViewer(containerRef, 'model.glb')
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg')
})
})
describe('cleanup', () => {
@@ -654,4 +679,63 @@ describe('useLoad3dViewer', () => {
expect(viewer.lightIntensity.value).toBe(1) // Default value
})
})
describe('standalone mode persistence', () => {
it('should save and restore configuration in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const model1 = 'model1.glb'
const model2 = 'model2.glb'
await viewer.initializeStandaloneViewer(containerRef, model1)
expect(viewer.isStandaloneMode.value).toBe(true)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
viewer.cameraType.value = 'orthographic'
viewer.fov.value = 45
viewer.lightIntensity.value = 2
viewer.backgroundImage.value = 'test.jpg'
viewer.backgroundRenderMode.value = 'tiled'
viewer.upDirection.value = '+y'
viewer.materialMode.value = 'wireframe'
await nextTick()
await viewer.initializeStandaloneViewer(containerRef, model2)
expect(viewer.backgroundColor.value).toBe('#282828')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.backgroundImage.value).toBe('')
await viewer.initializeStandaloneViewer(containerRef, model1)
expect(viewer.backgroundColor.value).toBe('#ff0000')
expect(viewer.showGrid.value).toBe(false)
expect(viewer.cameraType.value).toBe('orthographic')
expect(viewer.fov.value).toBe(45)
expect(viewer.lightIntensity.value).toBe(2)
expect(viewer.backgroundImage.value).toBe('test.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
expect(viewer.backgroundRenderMode.value).toBe('tiled')
expect(viewer.upDirection.value).toBe('+y')
expect(viewer.materialMode.value).toBe('wireframe')
await viewer.initializeStandaloneViewer(containerRef, model2)
expect(viewer.backgroundColor.value).toBe('#282828')
})
it('should save configuration during cleanup in standalone mode', async () => {
const viewer = useLoad3dViewer()
const containerRef = document.createElement('div')
const modelUrl = 'model_cleanup.glb'
await viewer.initializeStandaloneViewer(containerRef, modelUrl)
viewer.backgroundColor.value = '#0000ff'
await nextTick()
viewer.cleanup()
const newViewer = useLoad3dViewer()
await newViewer.initializeStandaloneViewer(containerRef, modelUrl)
expect(newViewer.backgroundColor.value).toBe('#0000ff')
})
})
})

View File

@@ -1,4 +1,5 @@
import { ref, toRaw, watch } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -33,9 +34,29 @@ interface Load3dViewerState {
materialMode: MaterialMode
}
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
backgroundColor: '#282828',
showGrid: true,
cameraType: 'perspective',
fov: 75,
lightIntensity: 1,
cameraState: null,
backgroundImage: '',
backgroundRenderMode: 'tiled',
upDirection: 'original',
materialMode: 'original'
}
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
maxSize: 50
})
/**
* @param node Optional node - if provided, viewer works in node mode with apply/restore
* If not provided, viewer works in standalone mode for asset preview
* Composable for managing a 3D viewer instance.
* Supports both node-based mode (applied to a LiteGraph node)
* and standalone mode (for asset previews).
*
* @param node Optional LiteGraph node to sync state with
*/
export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundColor = ref('')
@@ -64,6 +85,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
let currentModelUrl: string | null = null
const initialState = ref<Load3dViewerState>({
backgroundColor: '#282828',
@@ -206,6 +228,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
})
/**
* Seeks to a specific progress point in the current animation.
*
* @param progress Progress percentage (0-100)
*/
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
@@ -213,6 +240,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Sets up event listeners for animation-related events from the Load3d instance.
*/
const setupAnimationEvents = () => {
if (!load3d) return
@@ -243,7 +273,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in node mode (with source Load3d)
* Initializes the viewer in node mode using a source Load3d instance.
*
* @param containerRef The HTML element to mount the viewer in
* @param source The source Load3d instance to copy state from
*/
const initializeViewer = async (
containerRef: HTMLElement,
@@ -357,8 +390,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in standalone mode (for asset preview).
* Initializes the viewer in standalone mode for asset preview.
* Creates the Load3d instance once; subsequent calls reuse it.
*
* @param containerRef The HTML element to mount the viewer in
* @param modelUrl URL of the model to load
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -381,15 +417,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
})
await load3d.loadModel(modelUrl)
backgroundColor.value = '#282828'
showGrid.value = true
cameraType.value = 'perspective'
fov.value = 75
lightIntensity.value = 1
backgroundRenderMode.value = 'tiled'
upDirection.value = 'original'
materialMode.value = 'original'
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
@@ -410,7 +439,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!load3d) return
try {
saveStandaloneConfig()
await load3d.loadModel(modelUrl)
currentModelUrl = modelUrl
restoreStandaloneConfig(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
@@ -419,6 +451,53 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Saves the current viewer configuration to the standalone cache.
*/
function saveStandaloneConfig() {
if (!currentModelUrl) return
standaloneConfigCache.set(currentModelUrl, {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
cameraType: cameraType.value,
fov: fov.value,
lightIntensity: lightIntensity.value,
cameraState: load3d?.getCameraState() ?? null,
backgroundImage: backgroundImage.value,
backgroundRenderMode: backgroundRenderMode.value,
upDirection: upDirection.value,
materialMode: materialMode.value
})
}
/**
* Restores the viewer configuration from the standalone cache for the given model URL.
*
* @param modelUrl URL of the model to restore config for
*/
function restoreStandaloneConfig(modelUrl: string) {
const cached = standaloneConfigCache.get(modelUrl)
const config = cached ?? DEFAULT_STANDALONE_CONFIG
backgroundColor.value = config.backgroundColor
showGrid.value = config.showGrid
cameraType.value = config.cameraType
fov.value = config.fov
lightIntensity.value = config.lightIntensity
backgroundImage.value = config.backgroundImage
hasBackgroundImage.value = !!config.backgroundImage
backgroundRenderMode.value = config.backgroundRenderMode
upDirection.value = config.upDirection
materialMode.value = config.materialMode
if (cached?.cameraState && load3d) {
load3d.setCameraState(cached.cameraState)
}
}
/**
* Exports the current model in the specified format.
*
* @param format The export format (e.g., 'glb', 'obj')
*/
const exportModel = async (format: string) => {
if (!load3d) return
@@ -432,18 +511,30 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Handles resizing the 3D viewer.
*/
const handleResize = () => {
load3d?.handleResize()
}
/**
* Notifies the viewer that the mouse has entered the viewer area.
*/
const handleMouseEnter = () => {
load3d?.updateStatusMouseOnViewer(true)
}
/**
* Notifies the viewer that the mouse has left the viewer area.
*/
const handleMouseLeave = () => {
load3d?.updateStatusMouseOnViewer(false)
}
/**
* Restores the viewer state to its initial values when the viewer was opened.
*/
const restoreInitialState = () => {
if (!node) return
@@ -483,6 +574,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Applies the current viewer changes back to the source node and its properties.
*
* @returns Promise resolving to true if changes were applied successfully
*/
const applyChanges = async () => {
if (!node || !sourceLoad3d || !load3d) return false
@@ -527,10 +623,30 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
return true
}
/**
* Refreshes the viewport of the current Load3d instance.
*/
const refreshViewport = () => {
useLoad3dService().handleViewportRefresh(load3d)
}
/**
* Returns the subfolder path for file uploads based on the node properties.
*
* @returns The subfolder string
*/
const getUploadSubfolder = () => {
const resourceFolder = String(
node?.properties?.['Resource Folder'] ?? ''
).trim()
return resourceFolder ? `3d/${resourceFolder}` : '3d'
}
/**
* Handles updating the background image either by clearing it or uploading a new file.
*
* @param file The image file to upload, or null to clear the background
*/
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
backgroundImage.value = ''
@@ -538,18 +654,16 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
return
}
if (!node) {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(file, subfolder)
const uploadPath = await Load3dUtils.uploadFile(
file,
getUploadSubfolder()
)
if (uploadPath) {
backgroundImage.value = uploadPath
@@ -561,24 +675,22 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Handles dropping a new model file into the viewer.
*
* @param file The 3D model file to load
*/
const handleModelDrop = async (file: File) => {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
if (!node) {
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
const uploadedPath = await Load3dUtils.uploadFile(
file,
getUploadSubfolder()
)
if (!uploadedPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -594,7 +706,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
await load3d.loadModel(modelUrl)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const modelWidget = node?.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const options = modelWidget.options as { values?: string[] } | undefined
if (options?.values && !options.values.includes(uploadedPath)) {
@@ -608,10 +720,17 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
}
/**
* Cleans up the viewer resources and saves the current standalone config if applicable.
*/
const cleanup = () => {
if (isStandaloneMode.value) {
saveStandaloneConfig()
}
load3d?.remove()
load3d = null
sourceLoad3d = null
currentModelUrl = null
}
return {

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -27,9 +27,9 @@ import {
} from '@/stores/widgetValueStore'
import {
cleanupComplexPromotionFixtureNodeType,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
@@ -48,9 +48,14 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
@@ -62,7 +67,8 @@ function setupSubgraph(
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
function setPromotions(
@@ -97,13 +103,8 @@ function callSyncPromotions(node: SubgraphNode) {
)._syncPromotions()
}
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockDomWidgetStore.widgetStates.clear()
vi.clearAllMocks()
})
@@ -315,18 +316,10 @@ describe(createPromotedWidgetView, () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const store = useWidgetValueStore()
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
// Store label is undefined → falls back to displayName/widgetName
const state = store.getWidget(
subgraphNode.rootGraph.id,
bareId as never,
'myWidget'
)
state!.label = undefined
expect(view1.label).toBe('myWidget')
// With displayName → falls back to displayName
@@ -435,10 +428,6 @@ describe(createPromotedWidgetView, () => {
})
describe('SubgraphNode.widgets getter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'picker_input', type: '*' }]
@@ -576,7 +565,7 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
test('input-linked same-name widgets propagate value to all connected nodes while store-promoted peer stays independent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -631,53 +620,17 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
const widgetStore = useWidgetValueStore()
const graphId = subgraphNode.rootGraph.id
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent')
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
promotedView.value = 'independent-updated'
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent-updated')
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
@@ -1053,9 +1006,9 @@ describe('SubgraphNode.widgets getter', () => {
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const first = subgraphNode.widgets[0]
const second = subgraphNode.widgets[0]
@@ -1063,10 +1016,10 @@ describe('SubgraphNode.widgets getter', () => {
})
test('memoizes promotion list by reference', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const views1 = subgraphNode.widgets
expect(views1).toHaveLength(1)
@@ -1076,52 +1029,52 @@ describe('SubgraphNode.widgets getter', () => {
expect(views2[0]).toBe(views1[0])
// New store value with same content → same cached view object
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const views3 = subgraphNode.widgets
expect(views3[0]).toBe(views1[0])
})
test('cleans stale cache entries when promotions shrink', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
expect(subgraphNode.widgets).toHaveLength(2)
const viewA = subgraphNode.widgets[0]
// Remove widgetA from promotion list
setPromotions(subgraphNode, [['1', 'widgetB']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetB']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
// Re-adding widgetA creates a new view (old one was cleaned)
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
const newViewA = subgraphNode.widgets[1]
expect(newViewA).not.toBe(viewA)
})
test('deduplicates entries with same nodeId:widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetA']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(1)
})
test('setter is a no-op', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
// Assigning to widgets does nothing
subgraphNode.widgets = []
@@ -1471,14 +1424,10 @@ describe('SubgraphNode.widgets getter', () => {
})
describe('widgets getter caching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1506,9 +1455,9 @@ describe('widgets getter caching', () => {
})
test('does not re-run reconciliation when only canvas frame advances', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1573,19 +1522,19 @@ describe('widgets getter caching', () => {
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const [viewA, viewB] = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets[0]).toBe(viewB)
@@ -1593,15 +1542,15 @@ describe('widgets getter caching', () => {
})
test('deduplicates by key while preserving first-occurrence order', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA'],
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -1610,9 +1559,9 @@ describe('widgets getter caching', () => {
})
test('returns same array reference when promotions unchanged', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const result1 = subgraphNode.widgets
const result2 = subgraphNode.widgets
@@ -1620,16 +1569,16 @@ describe('widgets getter caching', () => {
})
test('returns new array after promotion change', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const result1 = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const result2 = subgraphNode.widgets
@@ -1638,12 +1587,12 @@ describe('widgets getter caching', () => {
})
test('invalidates cache on removeWidget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const result1 = subgraphNode.widgets
@@ -1657,30 +1606,26 @@ describe('widgets getter caching', () => {
})
describe('promote/demote cycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('promoting adds to store and widgets reflects it', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
expect(subgraphNode.widgets).toHaveLength(0)
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view = subgraphNode.widgets[0] as PromotedWidgetView
expect(view.sourceNodeId).toBe('1')
expect(view.sourceNodeId).toBe(innerIds[0])
expect(view.sourceWidgetName).toBe('widgetA')
})
test('demoting via removeWidget removes from store', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const viewA = subgraphNode.widgets[0]
@@ -1693,16 +1638,16 @@ describe('promote/demote cycle', () => {
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
])
})
test('full promote → demote → re-promote cycle', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
// Promote
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view1 = subgraphNode.widgets[0]
@@ -1711,7 +1656,7 @@ describe('promote/demote cycle', () => {
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote — creates a new view since the cache was cleared
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0]).not.toBe(view1)
expect(
@@ -1721,22 +1666,18 @@ describe('promote/demote cycle', () => {
})
describe('disconnected state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('view resolves type when interior widget exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
setPromotions(subgraphNode, [['1', 'numWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'numWidget']])
expect(subgraphNode.widgets[0].type).toBe('number')
})
test('keeps promoted entry as disconnected when interior node is removed', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
expect(subgraphNode.widgets[0].type).toBe('text')
@@ -1747,9 +1688,9 @@ describe('disconnected state', () => {
})
test('view recovers when interior widget is re-added', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
// Remove widget
innerNodes[0].widgets!.pop()
@@ -1831,10 +1772,6 @@ function createTwoLevelNestedSubgraph() {
}
describe('promoted combo rendering', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('draw shows value even when interior combo is computedDisabled', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -2151,7 +2088,6 @@ describe('promoted combo rendering', () => {
describe('DOM widget promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
@@ -2175,9 +2111,9 @@ describe('DOM widget promotion', () => {
}
test('draw registers position override for DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2189,9 +2125,9 @@ describe('DOM widget promotion', () => {
})
test('draw registers position override for component widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockComponentWidget(innerNodes[0], 'compWidget')
setPromotions(subgraphNode, [['1', 'compWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'compWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2203,9 +2139,9 @@ describe('DOM widget promotion', () => {
})
test('draw does not register override for non-DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'textWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'textWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
@@ -2232,14 +2168,14 @@ describe('DOM widget promotion', () => {
})
test('computeLayoutSize delegates to interior DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
domWidget.computeLayoutSize = vi.fn(() => ({
minHeight: 100,
maxHeight: 300,
minWidth: 0
}))
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
const result = view.computeLayoutSize!(subgraphNode)
@@ -2248,9 +2184,9 @@ describe('DOM widget promotion', () => {
})
test('demoting clears position override for DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
subgraphNode.removeWidget(view)
@@ -2261,12 +2197,12 @@ describe('DOM widget promotion', () => {
})
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'widgetA')
createMockDOMWidget(innerNodes[0], 'widgetB')
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
// Access widgets to populate cache

View File

@@ -264,4 +264,28 @@ describe('promoteRecommendedWidgets', () => {
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
const subgraph = createTestSubgraph()
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
})
})

View File

@@ -11,7 +11,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/useNodeCanvasImagePreview'
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'

View File

@@ -6,7 +6,8 @@ import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphI
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -61,6 +62,7 @@ function addLinkedInteriorInput(
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.clearAllMocks()
})
@@ -121,6 +123,21 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('seed_input')
})
test('resolves the first connected link when multiple links exist', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
// First connected wins — consistent with SubgraphNode._resolveLinkedPromotionBySubgraphInput
expect(result).toBe('first_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(

View File

@@ -19,9 +19,9 @@ export function resolveSubgraphInputLink<TResult>(
)
if (!inputSlot) return undefined
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
// Iterate forward so the first connected source is the promoted representative,
// matching SubgraphNode._resolveLinkedPromotionBySubgraphInput.
for (const linkId of inputSlot.linkIds) {
const link = node.subgraph.getLink(linkId)
if (!link) continue

View File

@@ -8,7 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -23,33 +24,35 @@ vi.mock('@/services/litegraphService', () => ({
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
@@ -57,18 +60,20 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'stringWidget' },
{ interiorNodeId: '2', widgetName: 'stringWidget' }
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' },
{ interiorNodeId: innerIds[1], widgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
@@ -77,14 +82,14 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
@@ -92,19 +97,19 @@ describe('Subgraph proxyWidgets', () => {
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
@@ -114,12 +119,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
@@ -133,12 +138,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
@@ -154,7 +159,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
@@ -162,7 +167,7 @@ describe('Subgraph proxyWidgets', () => {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
innerIds[0],
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
@@ -174,7 +179,7 @@ describe('Subgraph proxyWidgets', () => {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
innerIds[0],
'stringWidget'
)
expect(subgraphNode.widgets.length).toBe(1)
@@ -183,17 +188,19 @@ describe('Subgraph proxyWidgets', () => {
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -204,19 +211,19 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
).toStrictEqual([{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }])
})
test('removeWidgetByName removes from promotion list', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
]
)
@@ -227,12 +234,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
@@ -248,12 +255,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ interiorNodeId: innerIds[0], widgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
@@ -265,23 +272,23 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ interiorNodeId: innerIds[0], widgetName: 'widgetA' },
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
})
})

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { parseProxyWidgets } from './promotionSchema'
describe('parseProxyWidgets', () => {
describe('valid inputs', () => {
it('returns empty array for undefined', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
})
it('returns empty array for empty array', () => {
expect(parseProxyWidgets([])).toEqual([])
})
it('parses a single entry', () => {
expect(parseProxyWidgets([['1', 'seed']])).toEqual([['1', 'seed']])
})
it('parses multiple entries', () => {
const input = [
['1', 'seed'],
['2', 'steps']
]
expect(parseProxyWidgets(input)).toEqual(input)
})
it('parses a JSON string', () => {
expect(parseProxyWidgets('[["1", "seed"]]')).toEqual([['1', 'seed']])
})
it('parses a double-encoded JSON string', () => {
expect(parseProxyWidgets('"[[\\"1\\", \\"seed\\"]]"')).toEqual([
['1', 'seed']
])
})
})
describe('invalid inputs (resilient)', () => {
it('returns empty array for malformed JSON string', () => {
expect(parseProxyWidgets('not valid json')).toEqual([])
})
it('returns empty array for wrong tuple length', () => {
expect(parseProxyWidgets([['only-one']] as unknown as undefined)).toEqual(
[]
)
})
it('returns empty array for wrong shape', () => {
expect(
parseProxyWidgets({ wrong: 'shape' } as unknown as undefined)
).toEqual([])
})
it('returns empty array for number', () => {
expect(parseProxyWidgets(42)).toEqual([])
})
it('returns empty array for null', () => {
expect(parseProxyWidgets(null as unknown as undefined)).toEqual([])
})
it('returns empty array for empty string', () => {
expect(parseProxyWidgets('')).toEqual([])
})
})
})

View File

@@ -9,12 +9,17 @@ type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
throw new Error(`Invalid assignment for properties.proxyWidgets:\n${error}`)
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
}

View File

@@ -33,6 +33,8 @@ app.registerExtension({
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.setSize(this.computeSize())
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
this.isVirtualNode = true
}

View File

@@ -7,14 +7,16 @@ import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -103,7 +105,7 @@ useExtensionService().registerExtension({
config.configureForSaveMesh(loadFolder, filePath)
if (api.getServerFeature('assets', false)) {
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)

View File

@@ -142,7 +142,10 @@ app.registerExtension({
useToastStore().addAlert(err)
throw new Error(err)
}
return `webcam/${name} [temp]`
const data = await resp.json()
const serverName = data.name ?? name
const subfolder = data.subfolder ?? 'webcam'
return `${subfolder}/${serverName} [temp]`
}
// @ts-expect-error fixme ts strict error

View File

@@ -2972,14 +2972,14 @@ export class Subgraph
* @param input The input slot to remove.
*/
removeInput(input: SubgraphInput): void {
input.disconnect()
const index = this.inputs.indexOf(input)
if (index === -1) throw new Error('Input not found')
const mayContinue = this.events.dispatch('removing-input', { input, index })
if (!mayContinue) return
input.disconnect()
this.inputs.splice(index, 1)
const { length } = this.inputs
@@ -2993,8 +2993,6 @@ export class Subgraph
* @param output The output slot to remove.
*/
removeOutput(output: SubgraphOutput): void {
output.disconnect()
const index = this.outputs.indexOf(output)
if (index === -1) throw new Error('Output not found')
@@ -3004,6 +3002,8 @@ export class Subgraph
})
if (!mayContinue) return
output.disconnect()
this.outputs.splice(index, 1)
const { length } = this.outputs

View File

@@ -1207,6 +1207,14 @@ export class LGraphNode
: this.inputs[slot]
}
/**
* Resolves the output source for cross-graph virtual nodes (e.g. Set/Get),
* bypassing {@link getInputLink} when the source lives in a different graph.
*/
resolveVirtualOutput?(
slot: number
): { node: LGraphNode; slot: number } | undefined
/**
* Returns the link info in the connection of an input slot
* @returns object or null

View File

@@ -87,7 +87,7 @@ export { ContextMenu } from './ContextMenu'
export { DragAndScale } from './DragAndScale'
export { Rectangle } from './infrastructure/Rectangle'
export { RecursionError } from './infrastructure/RecursionError'
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
export type {
CanvasColour,
ColorOption,

View File

@@ -1,4 +1,3 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,10 +12,16 @@ import {
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('ExecutableNodeDTO Creation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('ExecutableNodeDTO Creation', () => {
it('should create DTO from regular node', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -106,7 +111,7 @@ describe.skip('ExecutableNodeDTO Creation', () => {
})
})
describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
describe('ExecutableNodeDTO Path-Based IDs', () => {
it('should generate simple ID for root node', () => {
const graph = new LGraph()
const node = new LGraphNode('Root Node')
@@ -160,7 +165,7 @@ describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
})
})
describe.skip('ExecutableNodeDTO Input Resolution', () => {
describe('ExecutableNodeDTO Input Resolution', () => {
it('should return undefined for unconnected inputs', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -202,7 +207,7 @@ describe.skip('ExecutableNodeDTO Input Resolution', () => {
})
})
describe.skip('ExecutableNodeDTO Output Resolution', () => {
describe('ExecutableNodeDTO Output Resolution', () => {
it('should resolve outputs for simple nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -382,7 +387,103 @@ describe('ALWAYS mode node output resolution', () => {
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
describe('Virtual node resolveVirtualOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should resolve through resolveVirtualOutput when implemented', () => {
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
sourceNode.addOutput('out', 'IMAGE')
graph.add(sourceNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({ node: sourceNode, slot: 0 })
graph.add(virtualNode)
const nodeDtoMap = new Map()
const sourceDto = new ExecutableNodeDTO(
sourceNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(sourceDto)
expect(resolved?.origin_slot).toBe(0)
})
it('should throw when resolveVirtualOutput returns a node with no matching DTO', () => {
const graph = new LGraph()
const unmappedNode = new LGraphNode('Unmapped Source')
unmappedNode.addOutput('out', 'IMAGE')
graph.add(unmappedNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => ({
node: unmappedNode,
slot: 0
})
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No DTO found for virtual source node'
)
})
it('should fall through to getInputLink when resolveVirtualOutput returns undefined', () => {
const graph = new LGraph()
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
const nodeDtoMap = new Map()
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(virtualDto.id, virtualDto)
const spy = vi.spyOn(virtualNode, 'getInputLink')
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
expect(spy).toHaveBeenCalledWith(0)
})
})
describe('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -417,7 +518,7 @@ describe.skip('ExecutableNodeDTO Properties', () => {
})
})
describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
describe('ExecutableNodeDTO Memory Efficiency', () => {
it('should create lightweight objects', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -441,7 +542,7 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
expect(dto.hasOwnProperty('widgets')).toBe(false) // Widgets not copied
})
it('should handle disposal without memory leaks', () => {
it('should drop local references without explicit disposal', () => {
const graph = new LGraph()
const nodes: ExecutableNodeDTO[] = []
@@ -484,19 +585,20 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
})
})
describe.skip('ExecutableNodeDTO Integration', () => {
describe('ExecutableNodeDTO Integration', () => {
it('should work with SubgraphNode flattening', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
const flattened = subgraphNode.getInnerNodes(new Map())
const idPattern = new RegExp(`^${subgraphNode.id}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0]).toBeInstanceOf(ExecutableNodeDTO)
expect(flattened[0].id).toMatch(/^1:\d+$/)
expect(flattened[0].id).toMatch(idPattern)
})
it.skip('should handle nested subgraph flattening', () => {
it('should handle nested subgraph flattening', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// This test needs investigation of how resolveSubgraphIdPath works
// Skip for now - will implement in edge cases test file
@@ -558,7 +660,7 @@ describe.skip('ExecutableNodeDTO Integration', () => {
})
})
describe.skip('ExecutableNodeDTO Scale Testing', () => {
describe('ExecutableNodeDTO Scale Testing', () => {
it('should create DTOs at scale', () => {
const graph = new LGraph()
const startTime = performance.now()

View File

@@ -291,6 +291,20 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return this._resolveSubgraphOutput(slot, type, visited)
if (node.isVirtualNode) {
// Cross-graph virtual nodes (e.g. Set/Get) resolve their source directly.
const virtualSource = this.node.resolveVirtualOutput?.(slot)
if (virtualSource) {
const inputNodeDto = [...this.nodesByExecutionId.values()].find(
(dto) =>
dto instanceof ExecutableNodeDTO && dto.node === virtualSource.node
)
if (!inputNodeDto)
throw new Error(
`No DTO found for virtual source node [${virtualSource.node.id}]`
)
return inputNodeDto.resolveOutput(virtualSource.slot, type, visited)
}
const virtualLink = this.node.getInputLink(slot)
if (virtualLink) {
const { inputNode } = virtualLink.resolve(this.graph)

View File

@@ -1,28 +1,31 @@
// TODO: Fix these tests after migration
/**
* Core Subgraph Tests
*
* This file implements fundamental tests for the Subgraph class that establish
* patterns for the rest of the testing team. These tests cover construction,
* basic I/O management, and known issues.
* patterns for the rest of the testing team. These tests cover construction
* and basic I/O management.
*/
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
createUuidv4,
RecursionError,
LGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
assertSubgraphStructure,
createTestSubgraph,
createTestSubgraphData
createTestSubgraphData,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('Subgraph Construction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph Construction', () => {
it('should create a subgraph with minimal data', () => {
const subgraph = createTestSubgraph()
@@ -44,11 +47,10 @@ describe.skip('Subgraph Construction', () => {
it('should require a root graph', () => {
const subgraphData = createTestSubgraphData()
const createWithoutRoot = () =>
new Subgraph(null as unknown as LGraph, subgraphData)
expect(() => {
// @ts-expect-error Testing invalid null parameter
new Subgraph(null, subgraphData)
}).toThrow('Root graph is required')
expect(createWithoutRoot).toThrow('Root graph is required')
})
it('should accept custom name and ID', () => {
@@ -63,31 +65,9 @@ describe.skip('Subgraph Construction', () => {
expect(subgraph.id).toBe(customId)
expect(subgraph.name).toBe(customName)
})
it('should initialize with empty inputs and outputs', () => {
const subgraph = createTestSubgraph()
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
expect(subgraph.widgets).toHaveLength(0)
})
it('should have properly configured input and output nodes', () => {
const subgraph = createTestSubgraph()
// Input node should be positioned on the left
expect(subgraph.inputNode.pos[0]).toBeLessThan(100)
// Output node should be positioned on the right
expect(subgraph.outputNode.pos[0]).toBeGreaterThan(300)
// Both should reference the subgraph
expect(subgraph.inputNode.subgraph).toBe(subgraph)
expect(subgraph.outputNode.subgraph).toBe(subgraph)
})
})
describe.skip('Subgraph Input/Output Management', () => {
describe('Subgraph Input/Output Management', () => {
subgraphTest('should add a single input', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test_input', 'number')
@@ -164,163 +144,3 @@ describe.skip('Subgraph Input/Output Management', () => {
expect(simpleSubgraph.outputs.indexOf(simpleSubgraph.outputs[0])).toBe(0)
})
})
describe.skip('Subgraph Serialization', () => {
subgraphTest('should serialize empty subgraph', ({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.version).toBe(1)
expect(serialized.id).toBeTruthy()
expect(serialized.name).toBe('Empty Test Subgraph')
expect(serialized.inputs).toHaveLength(0)
expect(serialized.outputs).toHaveLength(0)
expect(serialized.nodes).toHaveLength(0)
expect(typeof serialized.links).toBe('object')
})
subgraphTest(
'should serialize subgraph with inputs and outputs',
({ simpleSubgraph }) => {
const serialized = simpleSubgraph.asSerialisable()
expect(serialized.inputs).toHaveLength(1)
expect(serialized.outputs).toHaveLength(1)
expect(serialized.inputs![0].name).toBe('input')
expect(serialized.inputs![0].type).toBe('number')
expect(serialized.outputs![0].name).toBe('output')
expect(serialized.outputs![0].type).toBe('number')
}
)
subgraphTest(
'should include input and output nodes in serialization',
({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.inputNode).toBeDefined()
expect(serialized.outputNode).toBeDefined()
expect(serialized.inputNode.id).toBe(-10)
expect(serialized.outputNode.id).toBe(-20)
}
)
})
describe.skip('Subgraph Known Issues', () => {
it.skip('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
// but not actually enforced anywhere in the code.
//
// Expected behavior: Should throw error when nesting exceeds limit
// Actual behavior: No validation is performed
//
// This safety limit should be implemented to prevent runaway recursion.
})
it('should provide MAX_NESTED_SUBGRAPHS constant', () => {
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
})
it('should have recursion detection in place', () => {
// Verify that RecursionError is available and can be thrown
expect(() => {
throw new RecursionError('test recursion')
}).toThrow(RecursionError)
expect(() => {
throw new RecursionError('test recursion')
}).toThrow('test recursion')
})
})
describe.skip('Subgraph Root Graph Relationship', () => {
it('should maintain reference to root graph', () => {
const rootGraph = new LGraph()
const subgraphData = createTestSubgraphData()
const subgraph = new Subgraph(rootGraph, subgraphData)
expect(subgraph.rootGraph).toBe(rootGraph)
})
it('should inherit root graph in nested subgraphs', () => {
const rootGraph = new LGraph()
const parentData = createTestSubgraphData({
name: 'Parent Subgraph'
})
const parentSubgraph = new Subgraph(rootGraph, parentData)
// Create a nested subgraph
const nestedData = createTestSubgraphData({
name: 'Nested Subgraph'
})
const nestedSubgraph = new Subgraph(rootGraph, nestedData)
expect(nestedSubgraph.rootGraph).toBe(rootGraph)
expect(parentSubgraph.rootGraph).toBe(rootGraph)
})
})
describe.skip('Subgraph Error Handling', () => {
subgraphTest(
'should handle removing non-existent input gracefully',
({ emptySubgraph }) => {
// Create a fake input that doesn't belong to this subgraph
const fakeInput = emptySubgraph.addInput('temp', 'number')
emptySubgraph.removeInput(fakeInput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeInput(fakeInput)
}).toThrow('Input not found')
}
)
subgraphTest(
'should handle removing non-existent output gracefully',
({ emptySubgraph }) => {
// Create a fake output that doesn't belong to this subgraph
const fakeOutput = emptySubgraph.addOutput('temp', 'number')
emptySubgraph.removeOutput(fakeOutput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeOutput(fakeOutput)
}).toThrow('Output not found')
}
)
})
describe.skip('Subgraph Integration', () => {
it("should work with LGraph's node management", () => {
const subgraph = createTestSubgraph({
nodeCount: 3
})
// Verify nodes were added to the subgraph
expect(subgraph.nodes).toHaveLength(3)
// Verify we can access nodes by ID
const firstNode = subgraph.getNodeById(1)
expect(firstNode).toBeDefined()
expect(firstNode?.title).toContain('Test Node')
})
it('should maintain link integrity', () => {
const subgraph = createTestSubgraph({
nodeCount: 2
})
const node1 = subgraph.nodes[0]
const node2 = subgraph.nodes[1]
// Connect the nodes
node1.connect(0, node2, 0)
// Verify link was created
expect(subgraph.links.size).toBe(1)
// Verify link integrity
const link = Array.from(subgraph.links.values())[0]
expect(link.origin_id).toBe(node1.id)
expect(link.target_id).toBe(node2.id)
})
})

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { assert, describe, expect, it } from 'vitest'
import { assert, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import {
LGraphGroup,
@@ -8,11 +9,19 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNode(
graph: LGraph,
inputs: ISlotType[] = [],
@@ -40,8 +49,8 @@ function createNode(
graph.add(node)
return node
}
describe.skip('SubgraphConversion', () => {
describe.skip('Subgraph Unpacking Functionality', () => {
describe('SubgraphConversion', () => {
describe('Subgraph Unpacking Functionality', () => {
it('Should keep interior nodes and links', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -197,4 +206,43 @@ describe.skip('SubgraphConversion', () => {
expect(linkRefCount).toBe(4)
})
})
describe('Promotion cleanup on unpack', () => {
it('Should clear promotions for the unpacked subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNode = createNode(subgraph, [], ['number'])
innerNode.addWidget('text', 'myWidget', 'default', () => {})
const promotionStore = usePromotionStore()
const graphId = graph.id
const subgraphNodeId = subgraphNode.id
promotionStore.promote(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
expect(
promotionStore.isPromoted(
graphId,
subgraphNodeId,
String(innerNode.id),
'myWidget'
)
).toBe(true)
graph.unpackSubgraph(subgraphNode)
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
expect(
promotionStore.getPromotions(graphId, subgraphNodeId)
).toHaveLength(0)
})
})
})

View File

@@ -1,21 +1,28 @@
// TODO: Fix these tests after migration
/**
* SubgraphEdgeCases Tests
*
* Tests for edge cases, error handling, and boundary conditions in the subgraph system.
* This covers unusual scenarios, invalid states, and stress testing.
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphEdgeCases - Recursion Detection', () => {
it('should handle circular subgraph references without crashing', () => {
const sub1 = createTestSubgraph({ name: 'Sub1' })
const sub2 = createTestSubgraph({ name: 'Sub2' })
@@ -24,14 +31,11 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
const node1 = createTestSubgraphNode(sub1, { id: 1 })
const node2 = createTestSubgraphNode(sub2, { id: 2 })
// Current limitation: adding a circular reference overflows recursion depth.
sub1.add(node2)
sub2.add(node1)
// Should not crash or hang - currently throws path resolution error due to circular structure
expect(() => {
const executableNodes = new Map()
node1.getInnerNodes(executableNodes)
}).toThrow(/Node \[\d+\] not found/) // Current behavior: path resolution fails
sub2.add(node1)
}).toThrow(RangeError)
})
it('should handle deep nesting scenarios', () => {
@@ -48,20 +52,14 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
expect(firstLevel.isSubgraphNode()).toBe(true)
})
it.skip('should use WeakSet for cycle detection', () => {
// TODO: This test is currently skipped because cycle detection has a bug
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
it('should throw RangeError for self-referential subgraph', () => {
// Current limitation: creating self-referential subgraph instances overflows recursion depth.
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add to own subgraph to create cycle
subgraph.add(subgraphNode)
// Should throw due to cycle detection
const executableNodes = new Map()
expect(() => {
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(/while flattening subgraph/i)
subgraph.add(subgraphNode)
}).toThrow(RangeError)
})
it('should respect MAX_NESTED_SUBGRAPHS constant', () => {
@@ -76,7 +74,7 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
})
})
describe.skip('SubgraphEdgeCases - Invalid States', () => {
describe('SubgraphEdgeCases - Invalid States', () => {
it('should handle removing non-existent inputs gracefully', () => {
const subgraph = createTestSubgraph()
const fakeInput = {
@@ -120,7 +118,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addInput(undefinedString, 'number')
}).toThrow()
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
})
it('should handle null/undefined output names', () => {
@@ -135,7 +135,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addOutput(undefinedString, 'number')
}).toThrow()
}).not.toThrow()
expect(subgraph.outputs).toHaveLength(1)
})
it('should handle empty string names', () => {
@@ -160,11 +162,14 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
// Undefined type should throw error
expect(() => {
subgraph.addInput('test', undefinedString)
}).toThrow()
}).not.toThrow()
expect(() => {
subgraph.addOutput('test', undefinedString)
}).toThrow()
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
expect(subgraph.outputs).toHaveLength(1)
})
it('should handle duplicate slot names', () => {
@@ -185,7 +190,7 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
})
})
describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
describe('SubgraphEdgeCases - Boundary Conditions', () => {
it('should handle empty subgraphs (no nodes, no IO)', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -239,35 +244,9 @@ describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(1) // Original node count
})
it('should handle very long slot names', () => {
const subgraph = createTestSubgraph()
const longName = 'a'.repeat(1000) // 1000 character name
expect(() => {
subgraph.addInput(longName, 'number')
subgraph.addOutput(longName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(longName)
expect(subgraph.outputs[0].name).toBe(longName)
})
it('should handle Unicode characters in names', () => {
const subgraph = createTestSubgraph()
const unicodeName = '测试_🚀_تست_тест'
expect(() => {
subgraph.addInput(unicodeName, 'number')
subgraph.addOutput(unicodeName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(unicodeName)
expect(subgraph.outputs[0].name).toBe(unicodeName)
})
})
describe.skip('SubgraphEdgeCases - Type Validation', () => {
describe('SubgraphEdgeCases - Type Validation', () => {
it('should allow connecting mismatched types (no validation currently)', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -289,18 +268,6 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
}).not.toThrow()
})
it('should handle invalid type strings', () => {
const subgraph = createTestSubgraph()
// These should not crash (current behavior)
expect(() => {
subgraph.addInput('test1', 'invalid_type')
subgraph.addInput('test2', '')
subgraph.addInput('test3', '123')
subgraph.addInput('test4', 'special!@#$%')
}).not.toThrow()
})
it('should handle complex type strings', () => {
const subgraph = createTestSubgraph()
@@ -317,7 +284,7 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
})
})
describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
describe('SubgraphEdgeCases - Performance and Scale', () => {
it('should handle large numbers of nodes in subgraph', () => {
// Create subgraph with many nodes (keep reasonable for test speed)
const subgraph = createTestSubgraph({ nodeCount: 50 })
@@ -348,35 +315,4 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
})
it('should handle concurrent modifications safely', () => {
// This test ensures the system doesn't crash under concurrent access
// Note: JavaScript is single-threaded, so this tests rapid sequential access
const subgraph = createTestSubgraph({ nodeCount: 5 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate concurrent operations
const operations: Array<() => void> = []
for (let i = 0; i < 20; i++) {
operations.push(
() => {
const executableNodes = new Map()
subgraphNode.getInnerNodes(executableNodes)
},
() => {
subgraph.addInput(`concurrent_${i}`, 'number')
},
() => {
if (subgraph.inputs.length > 0) {
subgraph.removeInput(subgraph.inputs[0])
}
}
)
}
// Execute all operations - should not crash
expect(() => {
for (const op of operations) op()
}).not.toThrow()
})
})

View File

@@ -1,10 +1,11 @@
// TODO: Fix these tests after migration
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import { verifyEventSequence } from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphEvents - Event Payload Verification', () => {
describe('SubgraphEvents - Event Payload Verification', () => {
subgraphTest(
'dispatches input-added with correct payload',
({ eventCapture }) => {
@@ -199,9 +200,9 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
)
})
describe.skip('SubgraphEvents - Event Handler Isolation', () => {
describe('SubgraphEvents - Event Handler Isolation', () => {
subgraphTest(
'continues dispatching if handler throws',
'surfaces handler errors to caller and stops propagation',
({ emptySubgraph }) => {
const handler1 = vi.fn(() => {
throw new Error('Handler 1 error')
@@ -213,15 +214,15 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
emptySubgraph.events.addEventListener('input-added', handler2)
emptySubgraph.events.addEventListener('input-added', handler3)
// The operation itself should not throw (error is isolated)
// Current runtime behavior: listener exceptions bubble out of dispatch.
expect(() => {
emptySubgraph.addInput('test', 'number')
}).not.toThrow()
}).toThrowError('Handler 1 error')
// Verify all handlers were called despite the first one throwing
// Once the first listener throws, later listeners are not invoked.
expect(handler1).toHaveBeenCalled()
expect(handler2).toHaveBeenCalled()
expect(handler3).toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
expect(handler3).not.toHaveBeenCalled()
// Verify the throwing handler actually received the event
expect(handler1).toHaveBeenCalledWith(
@@ -229,24 +230,6 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
type: 'input-added'
})
)
// Verify other handlers received correct event data
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added',
detail: expect.objectContaining({
input: expect.objectContaining({
name: 'test',
type: 'number'
})
})
})
)
expect(handler3).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added'
})
)
}
)
@@ -305,7 +288,7 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
)
})
describe.skip('SubgraphEvents - Event Sequence Testing', () => {
describe('SubgraphEvents - Event Sequence Testing', () => {
subgraphTest(
'maintains correct event sequence for inputs',
({ eventCapture }) => {
@@ -351,7 +334,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
}
)
subgraphTest('handles concurrent event handling', ({ eventCapture }) => {
subgraphTest('fires all listeners synchronously', ({ eventCapture }) => {
const { subgraph, capture } = eventCapture
const handler1 = vi.fn(() => {
@@ -393,7 +376,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
)
})
describe.skip('SubgraphEvents - Event Cancellation', () => {
describe('SubgraphEvents - Event Cancellation', () => {
subgraphTest(
'supports preventDefault() for cancellable events',
({ emptySubgraph }) => {
@@ -443,71 +426,78 @@ describe.skip('SubgraphEvents - Event Cancellation', () => {
expect(emptySubgraph.inputs).toHaveLength(0)
expect(allowHandler).toHaveBeenCalled()
})
})
describe.skip('SubgraphEvents - Event Detail Structure Validation', () => {
subgraphTest('veto preserves input connections', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test', 'number')
const node = new LGraphNode('Interior')
node.addInput('in', 'number')
emptySubgraph.add(node)
input.connect(node.inputs[0], node)
expect(input.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-input', (event) => {
event.preventDefault()
})
emptySubgraph.removeInput(input)
expect(emptySubgraph.inputs).toContain(input)
expect(input.linkIds).not.toHaveLength(0)
})
subgraphTest('veto preserves output connections', ({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('test', 'number')
const node = new LGraphNode('Interior')
node.addOutput('out', 'number')
emptySubgraph.add(node)
output.connect(node.outputs[0], node)
expect(output.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-output', (event) => {
event.preventDefault()
})
emptySubgraph.removeOutput(output)
expect(emptySubgraph.outputs).toContain(output)
expect(output.linkIds).not.toHaveLength(0)
})
subgraphTest(
'validates all event detail structures match TypeScript types',
({ eventCapture }) => {
const { subgraph, capture } = eventCapture
'rename input cancellation does not prevent rename',
({ emptySubgraph }) => {
const input = emptySubgraph.addInput('original', 'number')
const input = subgraph.addInput('test_input', 'number')
subgraph.renameInput(input, 'renamed_input')
subgraph.removeInput(input)
const output = subgraph.addOutput('test_output', 'string')
subgraph.renameOutput(output, 'renamed_output')
subgraph.removeOutput(output)
const addingInputEvent = capture.getEventsByType('adding-input')[0]
expect(addingInputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
})
emptySubgraph.events.addEventListener('renaming-input', preventHandler)
const inputAddedEvent = capture.getEventsByType('input-added')[0]
expect(inputAddedEvent.detail).toEqual({
input: expect.any(Object)
})
emptySubgraph.renameInput(input, 'new_name')
const renamingInputEvent = capture.getEventsByType('renaming-input')[0]
expect(renamingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
expect(input.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
}
)
const removingInputEvent = capture.getEventsByType('removing-input')[0]
expect(removingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number)
})
subgraphTest(
'rename output cancellation does not prevent rename',
({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('original', 'number')
const addingOutputEvent = capture.getEventsByType('adding-output')[0]
expect(addingOutputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
})
emptySubgraph.events.addEventListener('renaming-output', preventHandler)
const outputAddedEvent = capture.getEventsByType('output-added')[0]
expect(outputAddedEvent.detail).toEqual({
output: expect.any(Object)
})
emptySubgraph.renameOutput(output, 'new_name')
const renamingOutputEvent = capture.getEventsByType('renaming-output')[0]
expect(renamingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
const removingOutputEvent = capture.getEventsByType('removing-output')[0]
expect(removingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number)
})
expect(output.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
}
)
})

View File

@@ -1,4 +1,3 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
@@ -7,17 +8,23 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
type InputWithWidget = {
_widget?: IWidget | { type: string; value: unknown; name: string }
_connection?: { id: number; type: string }
_listenerController?: AbortController
}
describe.skip('SubgraphNode Memory Management', () => {
describe.skip('Event Listener Cleanup', () => {
describe('SubgraphNode Memory Management', () => {
describe('Event Listener Cleanup', () => {
it('should register event listeners on construction', () => {
const subgraph = createTestSubgraph()
@@ -93,8 +100,8 @@ describe.skip('SubgraphNode Memory Management', () => {
})
})
describe.skip('Widget Promotion Memory Management', () => {
it('should clean up promoted widget references', () => {
describe('Widget Promotion Memory Management', () => {
it('should not mutate manually injected widget references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'testInput', type: 'number' }]
})
@@ -127,8 +134,8 @@ describe.skip('SubgraphNode Memory Management', () => {
subgraphNode.removeWidget(mockWidget)
// Widget should be removed from array
expect(subgraphNode.widgets).not.toContain(mockWidget)
// removeWidget only affects managed promoted widgets, not manually injected entries.
expect(subgraphNode.widgets).toContain(mockWidget)
})
it('should not leak widgets during reconfiguration', () => {
@@ -162,7 +169,7 @@ describe.skip('SubgraphNode Memory Management', () => {
})
})
describe.skip('SubgraphMemory - Event Listener Management', () => {
describe('SubgraphMemory - Event Listener Management', () => {
subgraphTest(
'event handlers still work after node creation',
({ emptySubgraph }) => {
@@ -254,35 +261,18 @@ describe.skip('SubgraphMemory - Event Listener Management', () => {
)
})
describe.skip('SubgraphMemory - Reference Management', () => {
it('properly manages subgraph references in root graph', () => {
describe('SubgraphMemory - Reference Management', () => {
it('maintains proper parent-child references while attached', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
const subgraphId = subgraph.id
// Add subgraph to root graph registry
rootGraph.subgraphs.set(subgraphId, subgraph)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
expect(rootGraph.subgraphs.get(subgraphId)).toBe(subgraph)
// Remove subgraph from registry
rootGraph.subgraphs.delete(subgraphId)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
})
it('maintains proper parent-child references', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ nodeCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
// Add to graph
rootGraph.add(subgraphNode)
expect(subgraphNode.graph).toBe(rootGraph)
expect(rootGraph.nodes).toContain(subgraphNode)
// Remove from graph
rootGraph.remove(subgraphNode)
expect(rootGraph.nodes).not.toContain(subgraphNode)
})
it('prevents circular reference creation', () => {
@@ -298,65 +288,7 @@ describe.skip('SubgraphMemory - Reference Management', () => {
})
})
describe.skip('SubgraphMemory - Widget Reference Management', () => {
subgraphTest(
'properly sets and clears widget references',
({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const input = subgraphNode.inputs[0]
// Mock widget for testing
const mockWidget = {
type: 'number',
value: 42,
name: 'test_widget'
}
// Set widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = mockWidget
expect((input as InputWithWidget)._widget).toBe(mockWidget)
}
// Clear widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = undefined
expect((input as InputWithWidget)._widget).toBeUndefined()
}
}
)
subgraphTest('maintains widget count consistency', ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const initialWidgetCount = subgraphNode.widgets?.length || 0
const widget1 = {
type: 'number',
value: 1,
name: 'widget1',
options: {},
y: 0
} as Partial<IWidget> as IWidget
const widget2 = {
type: 'string',
value: 'test',
name: 'widget2',
options: {},
y: 0
} as Partial<IWidget> as IWidget
if (subgraphNode.widgets) {
subgraphNode.widgets.push(widget1, widget2)
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
}
if (subgraphNode.widgets) {
subgraphNode.widgets.length = initialWidgetCount
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
}
})
describe('SubgraphMemory - Widget Reference Management', () => {
subgraphTest(
'cleans up references during node removal',
({ simpleSubgraph }) => {
@@ -399,7 +331,7 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
)
})
describe.skip('SubgraphMemory - Performance and Scale', () => {
describe('SubgraphMemory - Performance and Scale', () => {
subgraphTest(
'handles multiple subgraphs in same graph',
({ subgraphWithNode }) => {
@@ -450,29 +382,4 @@ describe.skip('SubgraphMemory - Performance and Scale', () => {
expect(rootGraph.nodes.length).toBe(0)
})
it('maintains consistent behavior across multiple cycles', () => {
const subgraph = createTestSubgraph()
const rootGraph = new LGraph()
for (let cycle = 0; cycle < 10; cycle++) {
const instances = []
// Create instances
for (let i = 0; i < 10; i++) {
const instance = createTestSubgraphNode(subgraph)
rootGraph.add(instance)
instances.push(instance)
}
expect(rootGraph.nodes.length).toBe(10)
// Remove instances
for (const instance of instances) {
rootGraph.remove(instance)
}
expect(rootGraph.nodes.length).toBe(0)
}
})
})

View File

@@ -1,25 +1,29 @@
// TODO: Fix these tests after migration
/**
* SubgraphNode Tests
*
* Tests for SubgraphNode instances including construction,
* IO synchronization, and edge cases.
*/
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphNode Construction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode Construction', () => {
it('should create a SubgraphNode from a subgraph definition', () => {
const subgraph = createTestSubgraph({
name: 'Test Definition',
@@ -102,7 +106,7 @@ describe.skip('SubgraphNode Construction', () => {
)
})
describe.skip('SubgraphNode Synchronization', () => {
describe('SubgraphNode Synchronization', () => {
it('should sync input addition', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -194,15 +198,7 @@ describe.skip('SubgraphNode Synchronization', () => {
})
})
describe.skip('SubgraphNode Lifecycle', () => {
it('should initialize with empty widgets array', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.widgets).toBeDefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
describe('SubgraphNode Lifecycle', () => {
it('should handle reconfiguration', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input1', type: 'number' }],
@@ -254,15 +250,7 @@ describe.skip('SubgraphNode Lifecycle', () => {
})
})
describe.skip('SubgraphNode Basic Functionality', () => {
it('should identify as subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.isSubgraphNode()).toBe(true)
expect(subgraphNode.isVirtualNode).toBe(true)
})
describe('SubgraphNode Basic Functionality', () => {
it('should inherit input types correctly', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -294,7 +282,7 @@ describe.skip('SubgraphNode Basic Functionality', () => {
})
})
describe.skip('SubgraphNode Execution', () => {
describe('SubgraphNode Execution', () => {
it('should flatten to ExecutableNodeDTOs', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -302,32 +290,39 @@ describe.skip('SubgraphNode Execution', () => {
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
const nodeId = subgraphNode.id
const idPattern = new RegExp(`^${nodeId}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0].id).toMatch(/^1:\d+$/) // Should have path-based ID like "1:1"
expect(flattened[1].id).toMatch(/^1:\d+$/)
expect(flattened[2].id).toMatch(/^1:\d+$/)
expect(flattened[0].id).toMatch(idPattern)
expect(flattened[1].id).toMatch(idPattern)
expect(flattened[2].id).toMatch(idPattern)
})
it.skip('should handle nested subgraph execution', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// Skip for now - similar issue to ExecutableNodeDTO nested test
// Will implement proper nested execution test in edge cases file
it('should handle nested subgraph execution', () => {
const rootGraph = new LGraph()
const childSubgraph = createTestSubgraph({
rootGraph,
name: 'Child',
nodeCount: 1
})
const parentSubgraph = createTestSubgraph({
rootGraph,
name: 'Parent',
nodeCount: 1
})
const childSubgraphNode = createTestSubgraphNode(childSubgraph, { id: 42 })
const childSubgraphNode = createTestSubgraphNode(childSubgraph, {
id: 42,
parentGraph: parentSubgraph
})
parentSubgraph.add(childSubgraphNode)
const parentSubgraphNode = createTestSubgraphNode(parentSubgraph, {
id: 10
id: 10,
parentGraph: rootGraph
})
rootGraph.add(parentSubgraphNode)
const executableNodes = new Map()
const flattened = parentSubgraphNode.getInnerNodes(executableNodes)
@@ -362,44 +357,16 @@ describe.skip('SubgraphNode Execution', () => {
})
it('should prevent infinite recursion', () => {
// Cycle detection properly prevents infinite recursion when a subgraph contains itself
// Circular self-references currently recurse in traversal; this test documents
// that execution flattening throws instead of silently succeeding.
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add subgraph node to its own subgraph (circular reference)
subgraph.add(subgraphNode)
const executableNodes = new Map()
expect(() => {
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(
/Circular reference detected.*infinite loop in the subgraph hierarchy/i
)
})
it('should handle nested subgraph execution', () => {
// This test verifies that subgraph nodes can be properly executed
// when they contain other nodes and produce correct output
const subgraph = createTestSubgraph({
name: 'Nested Execution Test',
nodeCount: 3
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: subgraph
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Verify that we can get executable DTOs for all nested nodes
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(3)
// Each DTO should have proper execution context
for (const dto of flattened) {
expect(dto).toHaveProperty('id')
expect(dto).toHaveProperty('graph')
expect(dto).toHaveProperty('inputs')
expect(dto.id).toMatch(/^\d+:\d+$/) // Path-based ID format
}
// Add subgraph node to its own subgraph (circular reference)
// add() itself throws due to recursive forEachNode traversal
expect(() => subgraph.add(subgraphNode)).toThrow()
})
it('should resolve cross-boundary links', () => {
@@ -427,7 +394,7 @@ describe.skip('SubgraphNode Execution', () => {
})
})
describe.skip('SubgraphNode Edge Cases', () => {
describe('SubgraphNode Edge Cases', () => {
it('should handle deep nesting', () => {
// Create a simpler deep nesting test that works with current implementation
const subgraph = createTestSubgraph({
@@ -451,18 +418,9 @@ describe.skip('SubgraphNode Edge Cases', () => {
expect(dto.id).toMatch(/^\d+:\d+$/)
}
})
it('should validate against MAX_NESTED_SUBGRAPHS', () => {
// Test that the MAX_NESTED_SUBGRAPHS constant exists
// Note: Currently not enforced in the implementation
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
// This test documents the current behavior - limit is not enforced
// TODO: Implement actual limit enforcement when business requirements clarify
})
})
describe.skip('SubgraphNode Integration', () => {
describe('SubgraphNode Integration', () => {
it('should be addable to a parent graph', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -494,39 +452,13 @@ describe.skip('SubgraphNode Integration', () => {
expect(parentGraph.nodes).toContain(subgraphNode)
parentGraph.remove(subgraphNode)
expect(parentGraph.nodes).not.toContain(subgraphNode)
expect(parentGraph.nodes.find((node) => node.id === subgraphNode.id)).toBe(
undefined
)
})
})
describe.skip('Foundation Test Utilities', () => {
it('should create test SubgraphNodes with custom options', () => {
const subgraph = createTestSubgraph()
const customPos: [number, number] = [500, 300]
const customSize: [number, number] = [250, 120]
const subgraphNode = createTestSubgraphNode(subgraph, {
pos: customPos,
size: customSize
})
expect(Array.from(subgraphNode.pos)).toEqual(customPos)
expect(Array.from(subgraphNode.size)).toEqual(customSize)
})
subgraphTest(
'fixtures should provide properly configured SubgraphNode',
({ subgraphWithNode }) => {
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
expect(subgraph).toBeDefined()
expect(subgraphNode).toBeDefined()
expect(parentGraph).toBeDefined()
expect(parentGraph.nodes).toContain(subgraphNode)
}
)
})
describe.skip('SubgraphNode Cleanup', () => {
describe('SubgraphNode Cleanup', () => {
it('should clean up event listeners when removed', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -544,10 +476,8 @@ describe.skip('SubgraphNode Cleanup', () => {
// Remove node2
rootGraph.remove(node2)
// Now trigger an event - only node1 should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Now trigger a real event through subgraph API - only node1 should respond
subgraph.addInput('test', 'number')
// Only node1 should have added an input
expect(node1.inputs.length).toBe(1) // node1 responds
@@ -571,10 +501,8 @@ describe.skip('SubgraphNode Cleanup', () => {
expect(node.inputs.length).toBe(0)
}
// Trigger an event - no nodes should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Trigger an event - no removed nodes should respond
subgraph.addInput('test', 'number')
// Without cleanup: all 3 removed nodes would have added an input
// With cleanup: no nodes should have added an input
@@ -698,6 +626,55 @@ describe('SubgraphNode duplicate input pruning (#9977)', () => {
})
})
describe('Nested SubgraphNode duplicate input prevention', () => {
it('should not duplicate inputs when the referenced subgraph is reconfigured', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'NUMBER' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
// Simulate what happens during nested subgraph configure:
// B.configure() calls _configureSubgraph(), which recreates SubgraphInput
// objects and dispatches 'input-added' events with new references.
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
// The SubgraphNode's event listener should recognize existing inputs
// by ID and NOT add duplicates.
expect(node.inputs).toHaveLength(2)
expect(node.inputs.every((i) => i._subgraphSlot)).toBe(true)
})
it('should not accumulate inputs across multiple reconfigure cycles', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph({
inputs: [
{ name: 'x', type: 'IMAGE' },
{ name: 'y', type: 'VAE' }
]
})
const node = createTestSubgraphNode(subgraph)
expect(node.inputs).toHaveLength(2)
for (let i = 0; i < 5; i++) {
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
}
expect(node.inputs).toHaveLength(2)
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,22 +1,23 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
interface MockPointerEvent {
canvasX: number
canvasY: number
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe.skip('SubgraphNode Title Button', () => {
describe.skip('Constructor', () => {
describe('SubgraphNode Title Button', () => {
describe('Constructor', () => {
it('should automatically add enter_subgraph button', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph',
@@ -30,10 +31,6 @@ describe.skip('SubgraphNode Title Button', () => {
const button = subgraphNode.title_buttons[0]
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe('enter_subgraph')
expect(button.text).toBe('\uE93B') // pi-window-maximize
expect(button.xOffset).toBe(-10)
expect(button.yOffset).toBe(0)
expect(button.fontSize).toBe(16)
})
it('should preserve enter_subgraph button when adding more buttons', () => {
@@ -52,7 +49,7 @@ describe.skip('SubgraphNode Title Button', () => {
})
})
describe.skip('onTitleButtonClick', () => {
describe('onTitleButtonClick', () => {
it('should open subgraph when enter_subgraph button is clicked', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph'
@@ -68,7 +65,7 @@ describe.skip('SubgraphNode Title Button', () => {
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
})
@@ -99,8 +96,8 @@ describe.skip('SubgraphNode Title Button', () => {
})
})
describe.skip('Integration with node click handling', () => {
it('should handle clicks on enter_subgraph button', () => {
describe('Integration with node click handling', () => {
it('should expose button hit testing that canvas uses for click routing', () => {
const subgraph = createTestSubgraph({
name: 'Nested Subgraph',
nodeCount: 3
@@ -130,66 +127,48 @@ describe.skip('SubgraphNode Title Button', () => {
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Simulate click on the enter button
const event: MockPointerEvent = {
canvasX: 275, // Near right edge where button should be
canvasY: 80 // In title area
}
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 275 - 100 = 175
80 - subgraphNode.pos[1] // 80 - 100 = -20
]
// @ts-expect-error onMouseDown possibly undefined
const handled = subgraphNode.onMouseDown(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
expect(handled).toBe(true)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
})
it('should not interfere with normal node operations', () => {
it('does not report hits outside the enter button area', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const enterButton = subgraphNode.title_buttons[0]
enterButton.getWidth = vi.fn().mockReturnValue(25)
enterButton.height = 20
enterButton._last_area[0] = 170
enterButton._last_area[1] = -30
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
// Click in the body of the node, not on button
const event: MockPointerEvent = {
canvasX: 200, // Middle of node
canvasY: 150 // Body area
}
const bodyClickRelativeToNode: [number, number] = [100, 50]
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
200 - subgraphNode.pos[0], // 200 - 100 = 100
150 - subgraphNode.pos[1] // 150 - 100 = 50
]
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
expect(
enterButton.isPointInside(
bodyClickRelativeToNode[0],
bodyClickRelativeToNode[1]
)
).toBe(false)
})
it('should not process button clicks when node is collapsed', () => {
it('keeps enter button metadata but canvas is responsible for collapsed guard', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
@@ -206,52 +185,18 @@ describe.skip('SubgraphNode Title Button', () => {
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Try to click on where the button would be
const event: MockPointerEvent = {
canvasX: 275,
canvasY: 80
}
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 175
80 - subgraphNode.pos[1] // -20
]
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
// Should not handle the click when collapsed
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
})
describe.skip('Visual properties', () => {
it('should have appropriate visual properties for enter button', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const enterButton = subgraphNode.title_buttons[0]
// Check visual properties
expect(enterButton.text).toBe('\uE93B') // pi-window-maximize
expect(enterButton.fontSize).toBe(16) // Icon size
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
expect(enterButton.yOffset).toBe(0) // Centered vertically
// Should be visible by default
expect(enterButton.visible).toBe(true)
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
expect(subgraphNode.flags.collapsed).toBe(true)
})
})
})

View File

@@ -38,6 +38,10 @@ import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVie
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -612,9 +616,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find(
(input) => input._subgraphSlot === subgraphInput
(input) =>
input._subgraphSlot === subgraphInput ||
(input._subgraphSlot && input._subgraphSlot.id === subgraphInput.id)
)
if (existingInput) {
// Rebind to the new SubgraphInput object and re-register listeners
// (configure recreates SubgraphInput objects with the same id)
this._addSubgraphInputListeners(subgraphInput, existingInput)
const linkId = subgraphInput.linkIds[0]
if (linkId === undefined) return
@@ -987,6 +996,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._syncPromotions()
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
if (
store.isPromoted(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
)
continue
store.promote(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
}
private _resolveInputWidget(

View File

@@ -1,20 +1,58 @@
// TODO: Fix these tests after migration
/**
* SubgraphSerialization Tests
*
* Tests for saving, loading, and version compatibility of subgraphs.
* This covers serialization, deserialization, data integrity, and migration scenarios.
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphSerialization - Basic Serialization', () => {
function createRegisteredNode(
graph: LGraph | Subgraph,
inputs: ISlotType[] = [],
outputs: ISlotType[] = [],
title?: string
) {
const type = JSON.stringify({ inputs, outputs })
if (!LiteGraph.registered_node_types[type]) {
class testnode extends LGraphNode {
constructor(title: string) {
super(title)
let i = 0
for (const input of inputs) this.addInput('input_' + i++, input)
let o = 0
for (const output of outputs) this.addOutput('output_' + o++, output)
}
}
LiteGraph.registered_node_types[type] = testnode
}
const node = LiteGraph.createNode(type, title)
if (!node) throw new Error('Failed to create node')
graph.add(node)
return node
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphSerialization - Basic Serialization', () => {
it('should save and load simple subgraphs', () => {
const original = createTestSubgraph({
name: 'Simple Test',
@@ -122,7 +160,7 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
})
})
describe.skip('SubgraphSerialization - Complex Serialization', () => {
describe('SubgraphSerialization - Complex Serialization', () => {
it('should serialize nested subgraphs with multiple levels', () => {
// Create a nested structure
const childSubgraph = createTestSubgraph({
@@ -189,35 +227,28 @@ describe.skip('SubgraphSerialization - Complex Serialization', () => {
}
})
it('should preserve custom node data', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Add custom properties to nodes (if supported)
const nodes = subgraph.nodes
if (nodes.length > 0) {
const firstNode = nodes[0]
if (firstNode.properties) {
firstNode.properties.customValue = 42
firstNode.properties.customString = 'test'
}
}
it('should preserve I/O even when nodes are not restored', () => {
const subgraph = createTestSubgraph({
nodeCount: 2,
inputs: [{ name: 'data_in', type: 'number' }],
outputs: [{ name: 'data_out', type: 'string' }]
})
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
// Custom properties preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0 && restored.nodes[0].properties) {
// Properties should be preserved if the node supports them
expect(restored.nodes[0].properties).toBeDefined()
}
// I/O is still preserved
expect(restored.inputs).toHaveLength(1)
expect(restored.inputs[0].name).toBe('data_in')
expect(restored.outputs).toHaveLength(1)
expect(restored.outputs[0].name).toBe('data_out')
})
})
describe.skip('SubgraphSerialization - Version Compatibility', () => {
describe('SubgraphSerialization - Version Compatibility', () => {
it('should handle version field in exports', () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const exported = subgraph.asSerialisable()
@@ -323,7 +354,7 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => {
})
})
describe.skip('SubgraphSerialization - Data Integrity', () => {
describe('SubgraphSerialization - Data Integrity', () => {
it('should pass round-trip testing (save → load → save → compare)', () => {
const original = createTestSubgraph({
name: 'Round Trip Test',
@@ -400,36 +431,48 @@ describe.skip('SubgraphSerialization - Data Integrity', () => {
expect(instance.outputs.length).toBe(1)
})
it('should preserve node positions and properties', () => {
it('should not restore nodes without registered types', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Modify node positions if possible
if (subgraph.nodes.length > 0) {
const node = subgraph.nodes[0]
if ('pos' in node) {
node.pos = [100, 200]
}
if ('size' in node) {
node.size = [150, 80]
}
}
// Nodes exist before serialization
expect(subgraph.nodes).toHaveLength(2)
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
})
// Position/size preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0) {
const restoredNode = restored.nodes[0]
expect(restoredNode).toBeDefined()
it('should preserve interior link structure through serialization', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
// Properties should be preserved if supported
if ('pos' in restoredNode && restoredNode.pos) {
expect(Array.isArray(restoredNode.pos)).toBe(true)
}
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
expect(subgraph.nodes).toHaveLength(3)
expect(subgraph.links.size).toBe(2)
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
restored.configure(exported)
expect(restored.nodes).toHaveLength(3)
expect(restored.links.size).toBe(2)
for (const [, link] of restored.links) {
const originNode = restored.getNodeById(link.origin_id)
const targetNode = restored.getNodeById(link.target_id)
expect(originNode).toBeDefined()
expect(targetNode).toBeDefined()
expect(link.origin_slot).toBeGreaterThanOrEqual(0)
expect(link.target_slot).toBeGreaterThanOrEqual(0)
expect(originNode!.outputs[link.origin_slot]).toBeDefined()
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
}
})
})

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import {
SUBGRAPH_INPUT_ID,
@@ -17,11 +18,17 @@ import type {
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('Subgraph slot connections', () => {
describe.skip('SubgraphInput connections', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph slot connections', () => {
describe('SubgraphInput connections', () => {
it('should connect to compatible regular input slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'test_input', type: 'number' }]
@@ -84,7 +91,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('SubgraphOutput connections', () => {
describe('SubgraphOutput connections', () => {
it('should connect from compatible regular output slots', () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
@@ -116,7 +123,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('LinkConnector dragging behavior', () => {
describe('LinkConnector dragging behavior', () => {
it('should drag existing link when dragging from input slot connected to subgraph input node', () => {
// Create a subgraph with one input
const subgraph = createTestSubgraph({
@@ -168,7 +175,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Type compatibility', () => {
describe('Type compatibility', () => {
it('should respect type compatibility for SubgraphInput connections', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
@@ -223,7 +230,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Type guards', () => {
describe('Type guards', () => {
it('should correctly identify SubgraphInput', () => {
const subgraph = createTestSubgraph()
const subgraphInput = subgraph.addInput('value', 'number')
@@ -251,7 +258,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Nested subgraphs', () => {
describe('Nested subgraphs', () => {
it('should handle dragging from SubgraphInput in nested subgraphs', () => {
const parentSubgraph = createTestSubgraph({
inputs: [{ name: 'parent_input', type: 'number' }],

View File

@@ -1,10 +1,14 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
interface MockColorContext {
defaultInputColor: string
@@ -13,12 +17,15 @@ interface MockColorContext {
getDisconnectedColor: ReturnType<typeof vi.fn>
}
describe.skip('SubgraphSlot visual feedback', () => {
describe('SubgraphSlot visual feedback', () => {
let mockCtx: CanvasRenderingContext2D
let mockColorContext: MockColorContext
let globalAlphaValues: number[]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
// Clear the array before each test
globalAlphaValues = []

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
ISlotType,
@@ -11,7 +12,8 @@ import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createEventCapture,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
// Helper to create a node with a widget
@@ -53,8 +55,13 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
describe.skip('SubgraphWidgetPromotion', () => {
describe.skip('Widget Promotion Functionality', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphWidgetPromotion', () => {
describe('Widget Promotion Functionality', () => {
it('should promote widgets when connecting node to subgraph input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
@@ -140,7 +147,10 @@ describe.skip('SubgraphWidgetPromotion', () => {
eventCapture.cleanup()
})
it('should fire widget-demoted event when removing promoted widget', () => {
// BUG: removeWidgetByName calls demote but widgets getter rebuilds from
// promotionStore which still has the entry.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/10174
it.skip('should fire widget-demoted event when removing promoted widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
@@ -284,7 +294,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
})
})
describe.skip('Tooltip Promotion', () => {
describe('Tooltip Promotion', () => {
it('should preserve widget tooltip when promoting', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]

View File

@@ -9,10 +9,12 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
LGraph,
Subgraph,
SubgraphEventMap,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { test as baseTest } from '../../__fixtures__/testExtensions'
@@ -20,14 +22,17 @@ const test = baseTest.extend({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
await use(undefined)
},
{ auto: true }
]
})
import {
createTestRootGraph,
createEventCapture,
createNestedSubgraphs,
resetSubgraphFixtureState,
createTestSubgraph,
createTestSubgraphNode
} from './subgraphHelpers'
@@ -133,8 +138,9 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
nodeCount: 1
})
const parentGraph = new LGraph()
const parentGraph = createTestRootGraph()
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph,
pos: [200, 200],
size: [180, 80]
})

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
@@ -6,12 +6,19 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
cleanupComplexPromotionFixtureNodeType,
createNestedSubgraphs,
createTestSubgraph,
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from './subgraphHelpers'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
describe('setupComplexPromotionFixture', () => {
beforeEach(() => {
resetSubgraphFixtureState()
})
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
@@ -29,4 +36,53 @@ describe('setupComplexPromotionFixture', () => {
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeUndefined()
})
it('builds a promotion fixture bound to a deterministic root graph', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { graph, subgraph, hostNode } = setupComplexPromotionFixture()
expect(graph.id).toBe('00000000-0000-4000-8000-000000000001')
expect(subgraph.rootGraph).toBe(graph)
expect(hostNode.graph).toBe(graph)
expect(hostNode.subgraph).toBe(subgraph)
expect(graph.getNodeById(hostNode.id)).toBe(hostNode)
})
})
describe('subgraph fixture graph setup', () => {
beforeEach(() => {
resetSubgraphFixtureState()
})
it('creates deterministic root and subgraph ids', () => {
const first = createTestSubgraph()
const second = createTestSubgraph()
expect(first.rootGraph.id).toBe('00000000-0000-4000-8000-000000000001')
expect(first.id).toBe('00000000-0000-4000-8000-000000000002')
expect(second.rootGraph.id).toBe('00000000-0000-4000-8000-000000000003')
expect(second.id).toBe('00000000-0000-4000-8000-000000000004')
})
it('creates nested subgraphs that share one root graph and valid parent chain', () => {
const nested = createNestedSubgraphs({
depth: 3,
nodesPerLevel: 1,
inputsPerSubgraph: 1,
outputsPerSubgraph: 1
})
expect(nested.subgraphs).toHaveLength(3)
expect(nested.subgraphNodes).toHaveLength(3)
expect(
nested.subgraphs.every(
(subgraph) => subgraph.rootGraph === nested.rootGraph
)
).toBe(true)
expect(nested.subgraphNodes[0].graph).toBe(nested.rootGraph)
expect(nested.subgraphNodes[1].graph).toBe(nested.subgraphs[0])
expect(nested.subgraphNodes[2].graph).toBe(nested.subgraphs[1])
})
})

View File

@@ -7,24 +7,27 @@
*/
import { expect } from 'vitest'
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraph,
ExportedSubgraphInstance,
ISlotType,
NodeId,
UUID
} from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
ExportedSubgraph,
ExportedSubgraphInstance
} from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
const FIXTURE_UUID_PREFIX = '00000000-0000-4000-8000-'
let fixtureUuidSequence = 1
class FixtureStringConcatenateNode extends LGraphNode {
constructor() {
@@ -43,7 +46,26 @@ export function cleanupComplexPromotionFixtureNodeType(): void {
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
}
function nextFixtureUuid(): UUID {
const suffix = fixtureUuidSequence.toString(16).padStart(12, '0')
fixtureUuidSequence += 1
return `${FIXTURE_UUID_PREFIX}${suffix}`
}
export function resetSubgraphFixtureState(): void {
fixtureUuidSequence = 1
cleanupComplexPromotionFixtureNodeType()
}
export function createTestRootGraph(id: UUID = nextFixtureUuid()): LGraph {
const graph = new LGraph()
graph.id = id
return graph
}
interface TestSubgraphOptions {
rootGraph?: LGraph
rootGraphId?: UUID
id?: UUID
name?: string
nodeCount?: number
@@ -54,6 +76,7 @@ interface TestSubgraphOptions {
}
interface TestSubgraphNodeOptions {
parentGraph?: LGraph | Subgraph
id?: NodeId
pos?: [number, number]
size?: [number, number]
@@ -112,20 +135,27 @@ export interface EventCapture<TEventMap extends object> {
export function createTestSubgraph(
options: TestSubgraphOptions = {}
): Subgraph {
if (options.rootGraph && options.rootGraphId) {
throw new Error(
"Cannot specify both 'rootGraph' and 'rootGraphId'. Choose one."
)
}
// Validate options - cannot specify both inputs array and inputCount
if (options.inputs && options.inputCount) {
throw new Error(
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach.`
)
}
// Validate options - cannot specify both outputs array and outputCount
if (options.outputs && options.outputCount) {
throw new Error(
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach.`
)
}
const rootGraph = new LGraph()
const rootGraph =
options.rootGraph ?? createTestRootGraph(options.rootGraphId)
const subgraphData: ExportedSubgraph = {
version: 1,
@@ -142,7 +172,7 @@ export function createTestSubgraph(
config: {},
definitions: { subgraphs: [] },
id: options.id || createUuidv4(),
id: options.id ?? nextFixtureUuid(),
name: options.name || 'Test Subgraph',
inputNode: {
@@ -217,10 +247,10 @@ export function createTestSubgraphNode(
subgraph: Subgraph,
options: TestSubgraphNodeOptions = {}
): SubgraphNode {
const parentGraph = new LGraph()
const parentGraph = options.parentGraph ?? subgraph.rootGraph
const instanceData: ExportedSubgraphInstance = {
id: options.id || 1,
id: options.id ?? parentGraph.state.lastNodeId + 1,
type: subgraph.id,
pos: options.pos || [100, 100],
size: options.size || [200, 100],
@@ -260,7 +290,7 @@ export function setupComplexPromotionFixture(): {
if (!hostNodeData)
throw new Error('Expected fixture to contain subgraph instance node id 21')
const graph = new LGraph()
const graph = createTestRootGraph()
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
subgraph.configure(subgraphData as ExportedSubgraph)
const hostNode = new SubgraphNode(
@@ -295,7 +325,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
outputsPerSubgraph = 1
} = options
const rootGraph = new LGraph()
const rootGraph = createTestRootGraph()
const subgraphs: Subgraph[] = []
const subgraphNodes: SubgraphNode[] = []
@@ -304,6 +334,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
for (let level = 0; level < depth; level++) {
// Create subgraph for this level
const subgraph = createTestSubgraph({
rootGraph,
name: `Level ${level} Subgraph`,
nodeCount: nodesPerLevel,
inputCount: inputsPerSubgraph,
@@ -313,6 +344,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
subgraphs.push(subgraph)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: currentParent,
pos: [100 + level * 200, 100]
})
@@ -434,7 +466,7 @@ export function createTestSubgraphData(
config: {},
definitions: { subgraphs: [] },
id: createUuidv4(),
id: nextFixtureUuid(),
name: 'Test Data Subgraph',
inputNode: {

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
LGraph,
@@ -10,11 +11,17 @@ import type { UUID } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('subgraphUtils', () => {
describe.skip('getDirectSubgraphIds', () => {
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('getDirectSubgraphIds', () => {
it('should return empty set for graph with no subgraph nodes', () => {
const graph = new LGraph()
const result = getDirectSubgraphIds(graph)
@@ -65,7 +72,7 @@ describe.skip('subgraphUtils', () => {
})
})
describe.skip('findUsedSubgraphIds', () => {
describe('findUsedSubgraphIds', () => {
it('should handle graph with no subgraphs', () => {
const graph = new LGraph()
const registry = new Map<UUID, LGraph>()
@@ -98,7 +105,7 @@ describe.skip('subgraphUtils', () => {
expect(result.has(subgraph2.id)).toBe(true)
})
it('should handle circular references without infinite loop', () => {
it('throws RangeError when graph.add() creates a circular subgraph reference', () => {
const rootGraph = new LGraph()
const subgraph1 = createTestSubgraph({ name: 'Subgraph 1' })
const subgraph2 = createTestSubgraph({ name: 'Subgraph 2' })
@@ -112,18 +119,9 @@ describe.skip('subgraphUtils', () => {
subgraph1.add(node2)
// Add subgraph1 to subgraph2 (circular reference)
// Note: add() itself throws RangeError due to recursive forEachNode
const node3 = createTestSubgraphNode(subgraph1, { id: 3 })
subgraph2.add(node3)
const registry = new Map<UUID, LGraph>([
[subgraph1.id, subgraph1],
[subgraph2.id, subgraph2]
])
const result = findUsedSubgraphIds(rootGraph, registry)
expect(result.size).toBe(2)
expect(result.has(subgraph1.id)).toBe(true)
expect(result.has(subgraph2.id)).toBe(true)
expect(() => subgraph2.add(node3)).toThrow(RangeError)
})
it('should handle missing subgraphs in registry gracefully', () => {

View File

@@ -136,9 +136,11 @@
"enableOrDisablePack": "Enable or disable pack",
"openManager": "Open Manager",
"manageExtensions": "Manage extensions",
"gallery": "Gallery",
"graphNavigation": "Graph navigation",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"previous": "Previous",
"next": "Next",
"submit": "Submit",
"install": "Install",
@@ -1156,6 +1158,7 @@
},
"maskEditor": {
"title": "Mask Editor",
"openMaskEditor": "Open in Mask Editor",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",

View File

@@ -22,10 +22,11 @@
import { useIntersectionObserver } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import { api } from '@/scripts/api'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { findServerPreviewUrl } from '../utils/assetPreviewUtil'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '../utils/assetPreviewUtil'
const { asset } = defineProps<{ asset: AssetMeta }>()
@@ -48,7 +49,7 @@ async function loadThumbnail() {
if (!asset?.src) return
if (asset.name && api.getServerFeature('assets', false)) {
if (asset.name && isAssetPreviewSupported()) {
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
if (serverPreviewUrl) {
thumbnailSrc.value = serverPreviewUrl

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
@@ -10,16 +11,27 @@ const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
(_story, context) => ({
components: { MediaLightbox },
setup() {
const galleryStore = useMediaAssetGalleryStore()
const args = context.args as {
onZoom?: (asset: AssetItem) => void
}
args.onZoom = (asset: AssetItem) => {
const kind = getMediaTypeFromFilename(asset.name)
galleryStore.openSingle({
...asset,
kind,
src: asset.preview_url || ''
})
}
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
<MediaLightbox
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
type MockItem = Pick<
ResultItemImpl,
'filename' | 'url' | 'isImage' | 'isVideo' | 'isAudio'
>
const SAMPLE_IMAGES: MockItem[] = [
{
filename: 'landscape.jpg',
url: 'https://i.imgur.com/OB0y6MR.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'portrait.jpg',
url: 'https://i.imgur.com/CzXTtJV.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'nature.jpg',
url: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
isImage: true,
isVideo: false,
isAudio: false
}
]
const meta: Meta<typeof MediaLightbox> = {
title: 'Platform/Assets/MediaLightbox',
component: MediaLightbox
}
export default meta
type Story = StoryObj<typeof meta>
export const MultipleImages: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Use arrow keys to navigate, Escape to close. Click backdrop to close.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
Open {{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const SingleImage: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(-1)
const items = [SAMPLE_IMAGES[0]] as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Single image — no navigation buttons shown.
</p>
<button
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = 0"
>
Open lightbox
</button>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const Closed: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(-1)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Lightbox is closed (activeIndex = -1). Click a button to open.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
{{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findOutputAsset,
findServerPreviewUrl,
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockApiURL = vi.hoisted(() =>
vi.fn((path: string) => `http://localhost:8188${path}`)
)
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())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi,
apiURL: mockApiURL,
api_base: '',
getServerFeature: mockGetServerFeature
}
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetAPIEnabled: mockIsAssetAPIEnabled,
uploadAssetFromBase64: mockUploadAssetFromBase64,
updateAsset: mockUpdateAsset
}
}))
function mockFetchResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ assets })
})
}
function mockFetchEmpty() {
mockFetchResponse([])
}
function mockFetchError() {
mockFetchApi.mockResolvedValueOnce({ ok: false })
}
const cloudAsset = {
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
name: 'mesh/ComfyUI_00003_.glb',
asset_hash: 'c6cadcee57dd.glb',
preview_id: null,
preview_url: undefined
}
const cloudAssetWithPreview = {
...cloudAsset,
preview_id: 'aaaa-bbbb',
preview_url: '/api/view?type=output&filename=preview.png'
}
const localAsset = {
id: '50bf419e-7ecb-4c96-a0c7-c1eb4dff00cb',
name: 'ComfyUI_00081_.glb',
preview_id: null,
preview_url:
'/api/view?type=output&filename=ComfyUI_00081_.glb&subfolder=mesh'
}
const localAssetWithPreview = {
...localAsset,
preview_id: '3df94ee8-preview',
preview_url: '/api/view?type=output&filename=preview.png'
}
describe('isAssetPreviewSupported', () => {
beforeEach(() => vi.clearAllMocks())
it('returns true when asset API is enabled (cloud)', () => {
mockIsAssetAPIEnabled.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns true when server assets feature is enabled (local)', () => {
mockGetServerFeature.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns false when neither is enabled', () => {
mockIsAssetAPIEnabled.mockReturnValue(false)
mockGetServerFeature.mockReturnValue(false)
expect(isAssetPreviewSupported()).toBe(false)
})
})
describe('findOutputAsset', () => {
beforeEach(() => vi.clearAllMocks())
it('finds asset by hash (cloud)', async () => {
mockFetchResponse([cloudAsset])
const result = await findOutputAsset('c6cadcee57dd.glb')
expect(mockFetchApi).toHaveBeenCalledOnce()
expect(mockFetchApi.mock.calls[0][0]).toContain(
'asset_hash=c6cadcee57dd.glb'
)
expect(result).toEqual(cloudAsset)
})
it('falls back to name_contains when hash returns empty (local)', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
const result = await findOutputAsset('ComfyUI_00081_.glb')
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
expect(result).toEqual(localAsset)
})
it('returns undefined when no asset matches', async () => {
mockFetchEmpty()
mockFetchEmpty()
const result = await findOutputAsset('nonexistent.glb')
expect(result).toBeUndefined()
})
it('matches exact name from name_contains results', async () => {
mockFetchEmpty()
mockFetchResponse([
{ id: '1', name: 'ComfyUI_00001_.glb_preview.png' },
{ id: '2', name: 'ComfyUI_00001_.glb' }
])
const result = await findOutputAsset('ComfyUI_00001_.glb')
expect(result?.id).toBe('2')
})
it('returns empty array on fetch error', async () => {
mockFetchError()
mockFetchError()
const result = await findOutputAsset('test.glb')
expect(result).toBeUndefined()
})
})
describe('findServerPreviewUrl', () => {
beforeEach(() => vi.clearAllMocks())
it('returns null when asset has no preview_id', async () => {
mockFetchResponse([cloudAsset])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBeNull()
})
it('returns preview_url via apiURL when preview_id is set', async () => {
mockFetchResponse([cloudAssetWithPreview])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(mockApiURL).toHaveBeenCalledWith(cloudAssetWithPreview.preview_url)
expect(result).toBe(
`http://localhost:8188${cloudAssetWithPreview.preview_url}`
)
})
it('constructs URL from preview_id when preview_url is missing', async () => {
mockFetchResponse([{ ...cloudAsset, preview_id: 'aaaa-bbbb' }])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBe('http://localhost:8188/assets/aaaa-bbbb/content')
})
it('falls back to asset id when preview_id is null but set', async () => {
// Edge case: asset has preview_id explicitly null, no preview_url
mockFetchEmpty()
mockFetchEmpty()
const result = await findServerPreviewUrl('nonexistent.glb')
expect(result).toBeNull()
})
it('returns null on error', async () => {
mockFetchApi.mockRejectedValueOnce(new Error('network error'))
const result = await findServerPreviewUrl('test.glb')
expect(result).toBeNull()
})
})
describe('persistThumbnail', () => {
beforeEach(() => vi.clearAllMocks())
it('uploads thumbnail and links preview_id', 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(mockUploadAssetFromBase64).toHaveBeenCalledOnce()
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'ComfyUI_00081_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(localAsset.id, {
preview_id: 'new-preview-id'
})
})
it('skips when asset already has preview_id', async () => {
mockFetchEmpty()
mockFetchResponse([localAssetWithPreview])
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('skips when no asset found', async () => {
mockFetchEmpty()
mockFetchEmpty()
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('nonexistent.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
})
it('swallows errors silently', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
const blob = new Blob(['fake-png'], { type: 'image/png' })
await expect(
persistThumbnail('ComfyUI_00081_.glb', blob)
).resolves.toBeUndefined()
})
it('works with cloud hash filename', 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(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'mesh/ComfyUI_00003_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(cloudAsset.id, {
preview_id: 'new-preview-id'
})
})
})

View File

@@ -1,50 +1,75 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
import { assetService } from '../services/assetService'
interface AssetRecord {
id: string
name: string
asset_hash?: string
preview_url?: string
preview_id?: string
preview_id?: string | null
}
async function fetchAssetsByName(name: string): Promise<AssetRecord[]> {
const params = new URLSearchParams({ name_contains: name })
const res = await api.fetchApi(`/assets?${params}`)
export function isAssetPreviewSupported(): boolean {
return (
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
)
}
async function fetchAssets(
params: Record<string, string>
): Promise<AssetRecord[]> {
const query = new URLSearchParams(params)
const res = await api.fetchApi(`/assets?${query}`)
if (!res.ok) return []
const data = await res.json()
return data.assets ?? []
}
function resolvePreviewUrl(asset: AssetRecord): string {
if (asset.preview_url) return api.apiURL(asset.preview_url)
const contentId = asset.preview_id ?? asset.id
return api.apiURL(`/assets/${contentId}/content`)
}
/**
* Find an output asset record by content hash, falling back to name.
* On cloud, output filenames are content-hashed; use asset_hash to match.
* On local, filenames are not hashed; use name_contains to match.
*/
export async function findOutputAsset(
name: string
): Promise<AssetRecord | undefined> {
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_hash === name)
if (hashMatch) return hashMatch
const byName = await fetchAssets({ name_contains: name })
return byName.find((a) => a.name === name)
}
export async function findServerPreviewUrl(
name: string
): Promise<string | null> {
try {
const assets = await fetchAssetsByName(name)
const asset = await findOutputAsset(name)
if (!asset?.preview_id) return null
const modelAsset = assets.find((a) => a.name === name)
if (!modelAsset?.preview_id) return null
const previewAsset = assets.find((a) => a.id === modelAsset.preview_id)
if (!previewAsset?.preview_url) return null
return api.api_base + previewAsset.preview_url
return resolvePreviewUrl(asset)
} catch {
return null
}
}
export async function persistThumbnail(
modelName: string,
name: string,
blob: Blob
): Promise<void> {
try {
const assets = await fetchAssetsByName(modelName)
const modelAsset = assets.find((a) => a.name === modelName)
if (!modelAsset || modelAsset.preview_id) return
const asset = await findOutputAsset(name)
if (!asset || asset.preview_id) return
const previewFilename = `${modelName}_preview.png`
const previewFilename = `${asset.name}_preview.png`
const uploaded = await assetService.uploadAssetFromBase64({
data: await blobToDataUrl(blob),
name: previewFilename,
@@ -52,7 +77,7 @@ export async function persistThumbnail(
user_metadata: { filename: previewFilename }
})
await assetService.updateAsset(modelAsset.id, {
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
} catch {

View File

@@ -56,9 +56,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
{
combo: {
ctrl: true,
shift: true,
key: 'a'
alt: true,
key: 'm'
},
commandId: 'Comfy.ToggleLinear'
},
@@ -179,7 +178,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
{
combo: {
key: 'm',
alt: true
alt: true,
shift: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
},

View File

@@ -35,7 +35,7 @@
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="copyToClipboard(model.representative.url!)"
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
>
{{ t('rightSidePanel.missingModels.copyUrl') }}
</Button>
@@ -201,7 +201,8 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import {
downloadModel,
isModelDownloadable
isModelDownloadable,
toBrowsableUrl
} from '@/platform/missingModel/missingModelDownload'
import { formatSize } from '@/utils/formatUtil'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { fetchModelMetadata } from './missingModelDownload'
import { fetchModelMetadata, toBrowsableUrl } from './missingModelDownload'
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
@@ -140,3 +140,41 @@ describe('fetchModelMetadata', () => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})
describe('toBrowsableUrl', () => {
it('replaces /resolve/ with /blob/ in HuggingFace URLs', () => {
expect(
toBrowsableUrl(
'https://huggingface.co/org/model/resolve/main/file.safetensors'
)
).toBe('https://huggingface.co/org/model/blob/main/file.safetensors')
})
it('returns non-HuggingFace URLs unchanged', () => {
const url =
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
expect(toBrowsableUrl(url)).toBe(url)
})
it('preserves query params in HuggingFace URLs', () => {
expect(
toBrowsableUrl(
'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors?download=true'
)
).toBe(
'https://huggingface.co/bfl/FLUX.1/blob/main/model.safetensors?download=true'
)
})
it('converts Civitai api/download URL to model page', () => {
expect(
toBrowsableUrl('https://civitai.com/api/download/models/12345')
).toBe('https://civitai.com/models/12345')
})
it('converts Civitai api/v1 URL to model page', () => {
expect(toBrowsableUrl('https://civitai.com/api/v1/models/12345')).toBe(
'https://civitai.com/models/12345'
)
})
})

View File

@@ -31,6 +31,21 @@ interface ModelWithUrl {
directory: string
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
* - Civitai: strips `/api/download` or `/api/v1` prefix (model page)
*/
export function toBrowsableUrl(url: string): string {
if (isCivitaiModelUrl(url)) {
return url.replace('/api/download/', '/').replace('/api/v1/', '/')
}
if (url.includes('huggingface.co')) {
return url.replace('/resolve/', '/blob/')
}
return url
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))

View File

@@ -576,6 +576,19 @@ export const CORE_SETTINGS: SettingParams[] = [
migrateDeprecatedValue: (value: unknown) =>
value === 'Integrated' ? 'Default' : value
},
{
id: 'Comfy.Appearance.DisableAnimations',
category: ['Appearance', 'General'],
name: 'Disable animations',
type: 'boolean',
defaultValue: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
tooltip:
'Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation.',
onChange: (value: unknown) => {
document.body.classList.toggle('disable-animations', !!value)
},
versionAdded: '1.43.0'
},
{
id: 'Comfy.UseNewMenu',
category: ['Comfy', 'Menu', 'UseNewMenu'],

View File

@@ -2,6 +2,16 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
function createInitializedProvider(): GtmTelemetryProvider {
window.__CONFIG__ = { gtm_container_id: 'GTM-TEST123' }
return new GtmTelemetryProvider()
}
function lastDataLayerEntry(): Record<string, unknown> | undefined {
const dl = window.dataLayer as unknown[] | undefined
return dl?.[dl.length - 1] as Record<string, unknown> | undefined
}
describe('GtmTelemetryProvider', () => {
beforeEach(() => {
window.__CONFIG__ = {}
@@ -66,4 +76,157 @@ describe('GtmTelemetryProvider', () => {
expect(gtagScripts).toHaveLength(1)
})
describe('event dispatch', () => {
it('pushes subscription modal as view_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('modal_opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'view_promotion' })
})
it('pushes subscribe click as select_promotion', () => {
const provider = createInitializedProvider()
provider.trackSubscription('subscribe_clicked')
expect(lastDataLayerEntry()).toMatchObject({ event: 'select_promotion' })
})
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false
})
})
it('pushes execution_error with truncated error', () => {
const provider = createInitializedProvider()
const longError = 'x'.repeat(200)
provider.trackExecutionError({
jobId: 'job-1',
nodeType: 'KSampler',
error: longError
})
const entry = lastDataLayerEntry()
expect(entry).toMatchObject({
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
})
it('pushes select_content for template events', () => {
const provider = createInitializedProvider()
provider.trackTemplate({
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_content',
content_type: 'template',
workflow_name: 'flux-dev',
template_category: 'image-gen'
})
})
it('pushes survey_opened for survey opened stage', () => {
const provider = createInitializedProvider()
provider.trackSurvey('opened')
expect(lastDataLayerEntry()).toMatchObject({ event: 'survey_opened' })
})
it('pushes survey_submitted with responses', () => {
const provider = createInitializedProvider()
provider.trackSurvey('submitted', {
familiarity: 'expert',
industry: 'gaming'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'survey_submitted',
familiarity: 'expert',
industry: 'gaming'
})
})
it('pushes email_verify_opened for opened stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('opened')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_opened'
})
})
it('pushes email_verify_completed for completed stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('completed')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_completed'
})
})
it('pushes search for node search (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearch({ query: 'KSampler' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'search',
search_term: 'KSampler'
})
})
it('pushes select_item for node search result (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearchResultSelected({
node_type: 'KSampler',
last_query: 'sampler'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'select_item',
item_id: 'KSampler',
search_term: 'sampler'
})
})
it('pushes setting_changed with setting_id', () => {
const provider = createInitializedProvider()
provider.trackSettingChanged({ setting_id: 'theme' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'setting_changed',
setting_id: 'theme'
})
})
it('pushes workflow_created with metadata', () => {
const provider = createInitializedProvider()
provider.trackWorkflowCreated({
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'workflow_created',
workflow_type: 'blank',
previous_workflow_had_nodes: true
})
})
it('pushes share_flow with step and source', () => {
const provider = createInitializedProvider()
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
})
})
it('does not push events when not initialized', () => {
window.__CONFIG__ = {}
const provider = new GtmTelemetryProvider()
provider.trackSubscription('modal_opened')
expect(window.dataLayer).toBeUndefined()
})
})
})

View File

@@ -1,8 +1,32 @@
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
TelemetryProvider
PageVisibilityMetadata,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
/**
@@ -84,9 +108,22 @@ export class GtmTelemetryProvider implements TelemetryProvider {
gtag('config', measurementId, { send_page_view: false })
}
private sanitizeProperties(
properties?: Record<string, unknown>
): Record<string, unknown> | undefined {
if (!properties) return undefined
return Object.fromEntries(
Object.entries(properties).map(([key, value]) => [
key,
typeof value === 'string' ? value.slice(0, 100) : value
])
)
}
private pushEvent(event: string, properties?: Record<string, unknown>): void {
if (!this.initialized) return
window.dataLayer?.push({ event, ...properties })
window.dataLayer?.push({ event, ...this.sanitizeProperties(properties) })
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
@@ -114,4 +151,210 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.pushEvent('begin_checkout', metadata)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
const ga4EventName =
event === 'modal_opened' ? 'view_promotion' : 'select_promotion'
this.pushEvent(ga4EventName, metadata ? { ...metadata } : undefined)
}
trackSignupOpened(): void {
this.pushEvent('signup_opened')
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown'
})
}
trackWorkflowExecution(): void {
this.pushEvent('execution_start')
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.pushEvent('execution_error', {
node_type: metadata.nodeType,
error: metadata.error
})
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.pushEvent('execution_success', {
job_id: metadata.jobId
})
}
trackTemplate(metadata: TemplateMetadata): void {
this.pushEvent('select_content', {
content_type: 'template',
workflow_name: metadata.workflow_name,
template_category: metadata.template_category
})
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.pushEvent('template_library_opened', {
source: metadata.source
})
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.pushEvent('template_library_closed', {
template_selected: metadata.template_selected,
time_spent_seconds: metadata.time_spent_seconds
})
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.pushEvent('workflow_import', {
missing_node_count: metadata.missing_node_count,
open_source: metadata.open_source
})
}
trackUserLoggedIn(): void {
this.pushEvent('user_logged_in')
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const ga4EventName =
stage === 'opened' ? 'survey_opened' : 'survey_submitted'
this.pushEvent(ga4EventName, responses ? { ...responses } : undefined)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
const eventMap = {
opened: 'email_verify_opened',
requested: 'email_verify_requested',
completed: 'email_verify_completed'
} as const
this.pushEvent(eventMap[stage])
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.pushEvent('workflow_opened', {
missing_node_count: metadata.missing_node_count,
open_source: metadata.open_source
})
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.pushEvent('workflow_saved', {
is_app: metadata.is_app,
is_new: metadata.is_new
})
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.pushEvent('default_view_set', {
default_view: metadata.default_view
})
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.pushEvent('app_mode_opened', {
source: metadata.source
})
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.pushEvent('share_flow', {
step: metadata.step,
source: metadata.source
})
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.pushEvent('page_visibility', {
visibility_state: metadata.visibility_state
})
}
trackTabCount(metadata: TabCountMetadata): void {
this.pushEvent('tab_count', {
tab_count: metadata.tab_count
})
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.pushEvent('search', {
search_term: metadata.query
})
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.pushEvent('select_item', {
item_id: metadata.node_type,
search_term: metadata.last_query
})
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.pushEvent('template_filter', {
search_query: metadata.search_query,
sort_by: metadata.sort_by,
filtered_count: metadata.filtered_count,
total_count: metadata.total_count
})
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.pushEvent('setting_changed', {
setting_id: metadata.setting_id
})
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.pushEvent('ui_button_click', {
button_id: metadata.button_id
})
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.pushEvent('help_center_opened', {
source: metadata.source
})
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.pushEvent('help_resource_click', {
resource_type: metadata.resource_type,
is_external: metadata.is_external,
source: metadata.source
})
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.pushEvent('help_center_closed', {
time_spent_seconds: metadata.time_spent_seconds
})
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.pushEvent('workflow_created', {
workflow_type: metadata.workflow_type,
previous_workflow_had_nodes: metadata.previous_workflow_had_nodes
})
}
trackAddApiCreditButtonClicked(): void {
this.pushEvent('add_credit_clicked')
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.pushEvent('credit_topup_clicked', {
credit_amount: amount
})
}
}

View File

@@ -470,7 +470,8 @@ function handleSubscribeWorkspace() {
}
function handleUpgrade() {
isFreeTierPlan.value ? showPricingTable() : showSubscriptionDialog()
if (isFreeTierPlan.value) showPricingTable()
else showSubscriptionDialog()
}
function handleUpgradeToAddCredits() {

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
onDragOver,
onDragDrop,
@@ -17,6 +20,7 @@ const {
imageUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
}
forceHovered?: boolean
}>()
@@ -91,7 +95,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'group/dropzone m-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@@ -108,12 +112,26 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
)
"
>
<img
<div
v-if="dropIndicator?.imageUrl"
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
class="relative max-h-full max-w-full"
>
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<button
v-if="dropIndicator?.onMaskEdit"
type="button"
:aria-label="t('maskEditor.openMaskEditor')"
:title="t('maskEditor.openMaskEditor')"
class="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background opacity-0 transition-colors duration-200 group-hover/dropzone:opacity-100 hover:bg-base-foreground/90"
@click.stop="dropIndicator?.onMaskEdit?.()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i

View File

@@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const initializeStandaloneViewer = vi.fn()
const cleanup = vi.fn()
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: () => ({
initializeStandaloneViewer,
cleanup,
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
handleResize: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
exportModel: vi.fn(),
handleSeek: vi.fn(),
isSplatModel: false,
isPlyModel: false,
hasSkeleton: false,
animations: [],
playing: false,
selectedSpeed: 1,
selectedAnimation: 0,
animationProgress: 0,
animationDuration: 0
})
}))
vi.mock('@/components/load3d/Load3DControls.vue', () => ({
default: { template: '<div />' }
}))
vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({
default: { template: '<div />' }
}))
describe('Preview3d', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
async function mountPreview3d(
modelUrl = 'http://localhost/view?filename=model.glb'
) {
const wrapper = mount(
(await import('@/renderer/extensions/linearMode/Preview3d.vue')).default,
{ props: { modelUrl } }
)
await nextTick()
await nextTick()
return wrapper
}
it('initializes the viewer on mount', async () => {
const wrapper = await mountPreview3d()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model.glb'
)
wrapper.unmount()
})
it('cleans up the viewer on unmount', async () => {
const wrapper = await mountPreview3d()
cleanup.mockClear()
wrapper.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes correctly after unmount and remount', async () => {
const url = 'http://localhost/view?filename=model.glb'
const wrapper1 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
cleanup.mockClear()
wrapper1.unmount()
expect(cleanup).toHaveBeenCalledOnce()
vi.clearAllMocks()
const wrapper2 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
url
)
cleanup.mockClear()
wrapper2.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes when modelUrl changes on existing instance', async () => {
const wrapper = await mountPreview3d(
'http://localhost/view?filename=model-a.glb'
)
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
vi.clearAllMocks()
await wrapper.setProps({
modelUrl: 'http://localhost/view?filename=model-b.glb'
})
await nextTick()
await nextTick()
expect(cleanup).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model-b.glb'
)
wrapper.unmount()
})
})

View File

@@ -13,11 +13,16 @@ const containerRef = useTemplateRef('containerRef')
const viewer = ref(useLoad3dViewer())
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
watch(
[containerRef, () => modelUrl],
async () => {
if (!containerRef.value || !modelUrl) return
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
viewer.value.cleanup()
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
},
{ flush: 'post' }
)
onUnmounted(() => {
viewer.value.cleanup()

View File

@@ -82,4 +82,71 @@ describe(flattenNodeOutput, () => {
const result = flattenNodeOutput(['1', output])
expect(result).toEqual([])
})
it('flattens non-standard output keys with ResultItem-like values', () => {
const output = makeOutput({
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as Partial<NodeExecutionOutput>)
const result = flattenNodeOutput(['10', output])
expect(result).toHaveLength(2)
expect(result.map((r) => r.filename)).toContain('before.png')
expect(result.map((r) => r.filename)).toContain('after.png')
})
it('excludes animated key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
})
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('accepts items with filename but no subfolder', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ filename: 'no-subfolder.png' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('valid.png')
expect(result[1].filename).toBe('no-subfolder.png')
expect(result[1].subfolder).toBe('')
})
it('excludes items missing filename', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})

View File

@@ -1,20 +1,10 @@
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { parseNodeOutput } from '@/stores/resultItemParsing'
import type { ResultItemImpl } from '@/stores/queueStore'
export function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
return parseNodeOutput(nodeId, nodeOutput)
}

View File

@@ -131,8 +131,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
@@ -227,7 +227,7 @@ const handleImageError = () => {
const handleEditMask = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -246,7 +246,7 @@ const handleDownload = () => {
const handleRemove = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
const node = resolveNode(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined

View File

@@ -6,6 +6,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -148,6 +149,14 @@ const mockNodeData: VueNodeData = {
executing: false
}
const mockRerouteNodeData: VueNodeData = {
...mockNodeData,
id: 'reroute-node-1',
title: '',
type: 'Reroute',
titleMode: TitleMode.NO_TITLE
}
describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
@@ -251,4 +260,37 @@ describe('LGraphNode', () => {
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})
describe('Reroute node sizing', () => {
it('should not enforce minimum width for reroute nodes', () => {
const wrapper = mountLGraphNode({ nodeData: mockRerouteNodeData })
const regularWrapper = mountLGraphNode({ nodeData: mockNodeData })
const rerouteHasMinWidth = wrapper
.classes()
.some((c) => c.startsWith('min-w-'))
const regularHasMinWidth = regularWrapper
.classes()
.some((c) => c.startsWith('min-w-'))
expect(rerouteHasMinWidth).toBe(false)
expect(regularHasMinWidth).toBe(true)
})
it('should use fixed height for reroute nodes', () => {
const wrapper = mountLGraphNode({ nodeData: mockRerouteNodeData })
const hasFixedHeight = wrapper.classes().some((c) => c.startsWith('h-'))
expect(hasFixedHeight).toBe(true)
})
it('should not render resize handle for reroute nodes', () => {
const wrapper = mountLGraphNode({ nodeData: mockRerouteNodeData })
expect(wrapper.find('[role="button"][aria-label]').exists()).toBe(false)
})
it('should render resize handle for regular nodes', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.find('[role="button"][aria-label]').exists()).toBe(true)
})
})
})

View File

@@ -11,7 +11,8 @@
:class="
cn(
'group/node lg-node absolute isolate text-sm',
'flex min-w-(--min-node-width) flex-col contain-layout contain-style',
'flex flex-col contain-layout contain-style',
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
@@ -75,7 +76,8 @@
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'min-h-(--node-height) w-(--node-width) min-w-(--min-node-width)',
'w-(--node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
@@ -129,7 +131,11 @@
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isCollapsed">
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data="nodeData" />
</template>
<template v-else-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
@@ -186,6 +192,7 @@
</template>
</div>
<NodeFooter
v-if="!isRerouteNode"
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
@@ -199,7 +206,12 @@
@toggle-advanced="handleToggleAdvanced"
/>
<template
v-if="!isCollapsed && nodeData.resizable !== false && !isSelectMode"
v-if="
!isCollapsed &&
!isRerouteNode &&
nodeData.resizable !== false &&
!isSelectMode
"
>
<div
v-for="handle in RESIZE_HANDLES"
@@ -367,6 +379,8 @@ const showErrorsTabEnabled = computed(() =>
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
const isRerouteNode = computed(() => nodeData.type === 'Reroute')
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
const bypassed = computed(
(): boolean => nodeData.mode === LGraphEventMode.BYPASS

View File

@@ -193,8 +193,8 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -374,7 +374,7 @@ function selectFromGrid(index: number) {
function handleEditMask() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
@@ -395,7 +395,7 @@ function handleDownload() {
function handleRemove() {
if (!nodeId) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
const node = resolveNode(Number(nodeId))
nodeOutputStore.removeNodeOutputs(nodeId)
if (node) {
node.imgs = undefined

View File

@@ -23,6 +23,8 @@ const zResultItem = z.object({
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
// Uses .passthrough() because custom nodes can output arbitrary keys.
// See docs/adr/0007-node-execution-output-passthrough-schema.md
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
@@ -298,6 +300,7 @@ const zSettings = z.object({
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Appearance.DisableAnimations': z.boolean(),
'Comfy.UI.TabBarLayout': z.enum(['Default', 'Legacy']),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),

View File

@@ -380,6 +380,7 @@ export class ComfyApi extends EventTarget {
}
apiURL(route: string): string {
if (route.startsWith('/api')) return this.api_base + route
return this.api_base + '/api' + route
}

View File

@@ -11,11 +11,11 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resultItemType } from '@/schemas/apiSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import { parseTaskOutput } from '@/stores/resultItemParsing'
const MAX_TASK_CACHE_SIZE = 50
const MAX_JOB_DETAIL_CACHE_SIZE = 50
@@ -79,65 +79,7 @@ export async function getOutputsForTask(
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, _]) => mediaType !== 'animated')
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItemLike).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItemLike(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (
candidate.filename !== undefined &&
typeof candidate.filename !== 'string'
) {
return false
}
if (
candidate.subfolder !== undefined &&
typeof candidate.subfolder !== 'string'
) {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
if (
candidate.filename === undefined &&
candidate.subfolder === undefined &&
candidate.type === undefined
) {
return false
}
return true
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))
}
export function getPreviewableOutputsFromJobDetail(

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