Compare commits

..

75 Commits

Author SHA1 Message Date
pythongosssss
9005a5a27f update bg 2026-07-02 08:02:31 -07:00
GitHub Action
49eab72789 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 14:58:28 +00:00
pythongosssss
429c1b952c remove 2026-07-02 07:53:37 -07:00
pythongosssss
2ff8a3afbc remove conflicting bgs 2026-07-02 07:37:11 -07:00
pythongosssss
3057caf9a5 Merge branch 'feat/app-mode-get-started-landing' into pysssss/app-mode-onboarding
# Conflicts:
#	src/renderer/extensions/linearMode/LinearWelcome.vue
2026-07-02 07:15:44 -07:00
pythongosssss
e92ac06920 Merge branch 'pysssss/product-coachmarks' into pysssss/app-mode-onboarding
# Conflicts:
#	src/components/appMode/AppModeToolbar.vue
#	src/renderer/extensions/linearMode/LinearControls.vue
#	src/views/LinearView.vue
2026-07-02 07:15:30 -07:00
pythongosssss
f17331cd35 Merge branch 'pysssss/appmode-credit-use-updates' into pysssss/app-mode-onboarding
# Conflicts:
#	src/locales/en/main.json
2026-07-02 07:14:32 -07:00
pythongosssss
02283a11ea Merge branch 'pysssss/app-mode-generation-animation' into pysssss/app-mode-onboarding 2026-07-02 07:14:14 -07:00
pythongosssss
801e2bfdfa Merge branch 'pysssss/app-mode-ui-updates' into pysssss/app-mode-onboarding 2026-07-02 07:14:14 -07:00
pythongosssss
3d518ccb7c Merge remote-tracking branch 'origin/main' into pysssss/app-mode-generation-animation 2026-07-02 07:13:42 -07:00
pythongosssss
df54ef9894 refactor: simplify generating-card image reveal, bound extra cards
- Replace the parent-side decode gate (decoded-src map, ready/decoding sets, liveness pruning) with a per-card image fade
- GeneratingCard derives its own src and fades it in on load; GeneratingScreen just filters cards that have content to show
- Latent-preview swaps stay flash-free via the browser keeping the prior frame until the newer src decodes — no manual bookkeeping
- Cap generatingExtraCards on insert instead of slicing at read time, so a long run no longer retains every non-selected output in memory
- Add an interactive GeneratingScreen story (add / remove / reset) to exercise the fan entrance, reflow, eviction and exit animations
2026-07-02 07:10:20 -07:00
pythongosssss
7526bc2b12 feat: add animated outputs
- add generating screen fan of images with pop in animations
- track arrival order to display cards ordered in fan
2026-07-02 07:10:20 -07:00
pythongosssss
72139d21a8 Merge remote-tracking branch 'origin/main' into feat/app-mode-get-started-landing 2026-07-02 06:51:36 -07:00
pythongosssss
ebfbb58f2d Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks 2026-07-02 06:51:36 -07:00
pythongosssss
36e1fb5c29 Merge remote-tracking branch 'origin/main' into pysssss/appmode-credit-use-updates 2026-07-02 06:50:29 -07:00
pythongosssss
fe644a9fb5 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates 2026-07-02 06:49:46 -07:00
pythongosssss
2c638d67c3 remove bg color change 2026-07-02 06:40:07 -07:00
pythongosssss
bb39a51d46 fix: narrow the coachmark i18n exemption to derived step keys
- only onboardingCoachmarks.<tour>.<step>.* is dynamically built;
  top-level keys (stepLabel, next, done, skip) stay checked
2026-07-02 06:15:25 -07:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00:00
pythongosssss
76abe8eb3f Merge branch 'main' into pysssss/product-coachmarks 2026-07-02 13:59:01 +01:00
pythongosssss
9bcfda88f6 refactor: reconcile v-coachmark from the element's own data-coach-id
- a single sync() replaces the per-hook register/unregister logic;
  mounted/updated/unmounted all reconcile desired vs current id
- drops the oldValue bookkeeping and its explanatory comment
2026-07-02 05:54:52 -07:00
pythongosssss
e7cdfc8c35 chore: ignore derived coachmark keys in the unused-i18n check
- tour step keys are built as onboardingCoachmarks.<tour>.<name>.*, so
  the literal-key scan can't see them
2026-07-02 05:47:10 -07:00
pythongosssss
e97746fd16 refactor: address coachmark tour PR feedback
- derive step translation keys from a step `name`
  (onboardingCoachmarks.<tour>.<name>.*), with te()-checked
  primary/skip label overrides falling back to Next/Done/Skip
- move all tour i18n into the engine; TourOverlay and TourSpotlight
  now receive translated title/body strings
- replace the landing's required open model with a skip emit and
  drop the landingOpen writable computed
- simplify TourOverlay.test.ts mocks with fromPartial
- replace SCRIM_COLOR with Tailwind classes (bg-black/60,
  spotlight spread shadow)
- narrow the app-run-button anchor so the spotlight excludes the
  run error warning
- key the e2e tour fixture's replay button by tour name
- drop the tour spec's template loading; the test server's default
  workflow already populates the graph (locally: pnpm dev:test)
- prune comments that restated code
2026-07-02 05:39:08 -07:00
pythongosssss
549200a76c fix: address coachmark tour stacking, telemetry, and fixture review notes
- clear the spotlight's previous ZIndex entry before re-raising, so step
  changes stop leaking entries into the shared modal stacking sequence
- report tour telemetry as the user-visible numbering: 1-based step_number
  within counted spotlight steps, omitted for the landing
- seed the e2e seen-tours setting from TOURS so future tours can't
  auto-open under unrelated suites
2026-07-02 01:31:08 -07:00
pythongosssss
1993bf4290 feat(appMode): rename run count to "Generations" and inline with stepper
- Relabel linear-mode run count from "Number of runs" to "Generations", uppercased
- Inline the label with the number stepper on the desktop run panel (flex wrap)
- Shrink label to text-xs in the foreground color
- Tighten vertical spacing and drop the top separator
2026-07-01 12:51:15 -07:00
pythongosssss
927f3f8541 feat(appMode): credits pill + breakdown popover on the run button
Surface a "Uses credits" pill on the app-mode run/subscribe button and a
per-node cost breakdown popover for priced (partner) nodes.

- Add CreditsPill ("Uses credits") to the run/subscribe button; collapses to a coin badge when the button is tight
- Extract useCreditsSummary; make PartnerNodesList a presentational breakdown with an approximate-cost info tooltip
- Show the breakdown via hover card (desktop) / popover (mobile), opening instantly when credits are required
- Add a brand-yellow button variant and apply it to the subscribe-to-run button
- Drop the subscribe-button tooltip when it only repeats the visible label
- Add e2e coverage for the credit breakdown popover
2026-07-01 12:25:43 -07:00
pythongosssss
979a832845 refactor(ui): unify credit icon to lucide--coins
Add a shared CREDITS_ICON constant and use it for the credit icons across balance, pricing, top-up and credit-slider surfaces, replacing the mixed comfy--credits / lucide--component usages.
2026-07-01 12:23:00 -07:00
pythongosssss
1c1c257f92 feat: redesign App mode empty-graph landing
- Empty graph: new "Get started with Apps" page (templates, import, discover)
- Populated: "Make this workflow an App" build-prompt card
- Share template source/app/thumbnail helpers via useTemplateWorkflows
- Drop unused welcome i18n keys and back-to-workflow button
- Update e2e selectors/specs; add unit tests
2026-07-01 11:25:19 -07:00
pythongosssss
96c2ae1182 feat: add landing image to the app-mode tour
- Show public/assets/images/app-mode-landing.png on the welcome landing's
  left panel
2026-07-01 10:19:44 -07:00
pythongosssss
2312b213ce refactor: address coachmark tour review feedback
- Drop the ?coach= query param: remove the forced-entry/replay-any override
  and delayed force-start; tours now start only via auto-open or an explicit
  request. E2E replays via the in-app help button after entering app mode
- Flatten coachmarkController to plain requestTour/onTourRequested exports
  instead of a useCoachmarkController composable
- Derive the top-bar safe inset from the --comfy-topbar-height token plus
  CARD_GAP instead of hardcoding 56
- Remove the landing Start button's fixed width
- Document the real cause of the landing Escape workaround (global keybinding
  preventDefaults Escape before Reka's DismissableLayer dismisses)
- Trim verbose comments in coachmarkRegistry and TourOverlay tests
2026-07-01 10:07:51 -07:00
pythongosssss
ce8b107322 fix: correct coachmark tour skip telemetry and one-shot forcing
- Report the timed-out deferred step in skip telemetry by advancing the
  step index before ending the tour, instead of logging the prior step
- Make the ?coach= override one-shot: named paths force-start only via the
  delayed timer (respecting START_DELAY_MS) and re-entry honors the
  seen-flag again, rather than bypassing it all session
- Resolve waitForTarget to false immediately when the signal is already
  aborted, so it can't hang until timeout
- Reuse the cached dialog locator in Tour.cardForStep
2026-07-01 09:14:15 -07:00
pythongosssss
831813a9db fix: stop coachmark spotlight polling once its target settles
- Drive Floating UI autoUpdate manually so animationFrame polling runs
  only while a deferred target is still moving, then fall back to
  scroll/resize listeners instead of polling every frame for the whole step
- Set aria-modal to false on interaction steps where the user must click
  outside the card
- Ignore unrecognized ?coach= values; keep `any` as the replay keyword
- Align the spotlight scrim with the landing backdrop (0.62 -> 0.6) to
  avoid a dim shift on landing -> spotlight
- Export COACH_IDS and import it in the drift guard instead of a hardcoded
  in-sync list
- Clarify why the landing needs an explicit Escape listener (Reka's
  DismissableLayer doesn't fire update:open here)
- Test that the started telemetry event omits step_index/coach_id while
  per-step events include them

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:43:19 -07:00
pythongosssss
6455a49f58 test(appMode): drop ErrorOverlay assertion from LinearView
ErrorOverlay was removed from LinearView in #12557 (replaced by the app
mode run validation warning); update the merged test to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:14:04 -07:00
pythongosssss
64d10da9d7 refactor: place coachmark cards with Floating UI
- Replace the hand-rolled card-placement math (resolvePlacement /
  cardCorner / clampCardPosition) and the manual target tracking (scroll
  listener + useElementBounding + rAF settle loop) with @floating-ui/vue
  (offset/flip/shift + autoUpdate); promote it from a transitive to a
  direct dependency.
- Keep vertically-centred (leftCenter) cards on-screen with
  shift({ crossAxis: true }), and centre the card when its target hasn't
  laid out, so a step never renders off-screen or invisible.
- Make targetMounted/waitForTarget layout-aware (poll per frame) so a
  deferred target that registers before it sizes resolves only once
  measurable, matching what the spotlight actually displays.
- Shrink the spotlight glow (pad 8->4) so it no longer spills onto an
  adjacent control the user might click.
- Label the spotlight dialog with aria-labelledby pointing at its heading
  instead of a duplicated aria-label.
- Drop the unused isActive option from useFocusTrap.
- Add an e2e guard that walks every app-mode spotlight step and asserts
  each card sits fully within the viewport; add unit coverage for
  last-step Skip hiding, modal z-index reclaim, and all four onboarding
  telemetry stages.
2026-07-01 06:20:03 -07:00
pythongosssss
3f84d4f5f2 test: cover forced tour start, button labels, and spotlight escape
Raise useCoachmarkTour.ts to full line coverage and TourSpotlight.vue escape handling with behavioral tests.
2026-07-01 03:45:44 -07:00
pythongosssss
b846cf4171 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	src/views/LinearView.vue
2026-07-01 03:22:28 -07:00
pythongosssss
5383e23d24 Merge origin/main into pysssss/product-coachmarks
Conflict in LinearControls.vue: main refactored the run-button test id into a
named constant (linearRunButtonTestId, same 'linear-run-button' value), while
this branch added the v-coachmark anchor and a static test id to the same
sections. Kept the coachmark directive and adopted main's :data-testid binding,
dropping the now-redundant static id.
2026-07-01 02:57:44 -07:00
pythongosssss
9b8dd27f3d refactor: gate coachmark tour listeners behind an active-step component
Extract TourSpotlight, rendered only while a spotlight step is shown, so the
geometry/scroll, focus-trap and click-to-advance listeners (plus z-index and the
stall pulse) register for the active step rather than the whole graph-view
session. useCoachmarkTour slims to the state machine; the registry-query helpers
(targetMounted/waitForTarget) move to coachmarkRegistry and useCoachmarkTarget
becomes geometry-only and self-measuring.

Also from review feedback:
- read the ?coach= force in setup, before the immediate auto-open watcher, so an
  already-populated app no longer drops a ?coach=any replay
- harden isEntryPath against prototype keys (Object.hasOwn)
- decouple each tour's auto-open condition into useTourTriggers, keeping the
  engine tour-agnostic
2026-06-30 14:00:43 -07:00
pythongosssss
a818b7eee8 test: open the template browser in the coachmark drift guard
Trimming the graph-mode anchors earlier removed the templates-button
click that loadTemplate relied on to open the browser; open it via the
Comfy.BrowseTemplates command instead.
2026-06-30 12:48:49 -07:00
pythongosssss
87d0a110cd fix: close the coachmark landing on Escape explicitly
Reka's built-in Escape dismissal proved unreliable for this dialog in
e2e (Skip, which sets open=false directly, works); close via the same
model path on Escape so the welcome landing reliably dismisses.
2026-06-30 12:41:24 -07:00
pythongosssss
b65da23915 refactor: slim coachmark target tracking and spotlight chrome
- Track the spotlight target with VueUse useElementBounding instead of a
  hand-rolled measure/RAF/ResizeObserver loop; keep a capture-phase scroll
  listener so it still follows targets inside scrollable panels
- Fold the spotlight ring into the dim element (CSS outline + box-shadow),
  dropping the separate SVG; the idle pulse now animates outline-color
- Remove the unused CoachStep `elevated` field and its plumbing
2026-06-30 12:07:22 -07:00
pythongosssss
07356e3253 tidy 2026-06-30 11:19:58 -07:00
pythongosssss
51182127f3 feat: auto-open app-mode tour for populated apps
- Open the app-mode tour when entering an app with linear controls
  visible (mode === 'app' && hasOutputs), including when the overlay
  mounts into one already (immediate watch)
- Respect the seen-flag, so it won't reopen once completed or skipped;
  skip the empty/welcome state and arrange (builder) mode
- Move test target DOM cleanup into afterEach so appended nodes are
  removed even if a test fails early
2026-06-30 10:49:42 -07:00
pythongosssss
fdfa9882b1 feat: make app-mode tour explicit-only and refine assets step
- Open the tour only via the help button or ?coach= param (no auto-open
  when entering app mode)
- Drop the loadTemplate step tech and the demo card image — the tour runs
  on the user's existing app
- Skip the assets-button step at tour start when the assets panel is
  already open; step indicator counts 4 instead of 5
2026-06-30 08:27:53 -07:00
github-actions
e970f5457b [automated] Update test expectations 2026-06-30 12:29:04 +00:00
pythongosssss
06d5443de1 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
2026-06-30 05:21:59 -07:00
pythongosssss
86219d117d test(appMode): assert builder exit lands in graph mode 2026-06-30 05:16:42 -07:00
pythongosssss
88cd848245 fix test 2026-06-30 04:39:29 -07:00
pythongosssss
5dbb560ef0 refactor + tidy 2026-06-30 04:35:24 -07:00
pythongosssss
f973626ebc improve test coverage 2026-06-30 03:21:59 -07:00
pythongosssss
e9729ca272 drop blankCanvas tour 2026-06-30 02:44:00 -07:00
pythongosssss
c51f963ef2 trim comments 2026-06-30 02:20:03 -07:00
pythongosssss
5e23f76642 fix: address coachmark tour review feedback
- onboardingTours: use 'auto' placement for assets panel so the card follows the sidebar side
- TourOverlay: clamp no-target card left to the viewport margin so it never goes off-screen
- useCoachmarkTour: catch onPrimary action failures, surface a toast, and only advance on success
- useCoachmarkTour: claim the single-instance guard synchronously so a duplicate mount stays inert
- useFocusTrap: send Shift+Tab from outside the trap to the last item instead of skipping it
- telemetry: drop dead run-button imports left over from the rebase (superseded by getRunButtonTelemetryProperties)
2026-06-30 02:02:27 -07:00
pythongosssss
f642384674 change dash stroke to solid 2026-06-30 02:02:27 -07:00
GitHub Action
f80deb9655 [automated] Apply ESLint and Oxfmt fixes 2026-06-29 21:57:11 +00:00
pythongosssss
75af0430fc - change dialog to use standard component
- move tour trigger to linear controls section
2026-06-29 14:48:18 -07:00
pythongosssss
cc74e1dc65 add assets step to app mode tour 2026-06-29 14:48:18 -07:00
pythongosssss
989773995a feat: add product coachmark onboarding tours
- add blank canvas (demo) and app mode (wip) tours
- overlay with target highlight, landing card, step state handling, step card
- v-coachmark directive updating registry for mount/unmount
- frame settling watcher for animated targets (dialogs)
- focus trap for the target plus the coachmark element
- add telemetry for each step
2026-06-29 14:48:18 -07:00
github-actions
8ee6fc6f5f [automated] Update test expectations 2026-06-29 13:26:57 +00:00
pythongosssss
d9fd2e8c2f Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
2026-06-29 06:06:23 -07:00
github-actions
414469ed3c [automated] Update test expectations 2026-06-29 13:00:26 +00:00
pythongosssss
8e0622e423 test: assert slot-prop contract in sidebar tab tests
- SideToolbar: assert logout icon absent for single-user before multi-user
- AppsSidebarTab: drive stubs by real hasResults and button-label props
2026-06-29 05:22:39 -07:00
pythongosssss
be251d540a test: cover app-mode UI changed lines for patch coverage
Add unit tests so the branch's changed lines in the app-mode UI are covered
by the unit flag (Codecov patch coverage):

- WorkflowActionsDropdown: segment labels, mode toggle, open telemetry
- SideToolbar: visibleTabIds filter, forceConnected, linearMode toggles
- AppsSidebarTab: createApp from header action and empty state
- AppModeToolbar: build-an-app button enable/disable/enter builder
- SubgraphBreadcrumb: actions dropdown gated by linearMode
- LinearPreview: output-history render branches across mobile/builder modes
- LinearView: layout/panel branches and splitter resize handlers

All changed instrumented lines in these files are now covered. The single
changed line in GraphCanvas.vue (SideToolbar v-if) remains e2e-covered.
2026-06-26 12:46:00 -07:00
pythongosssss
6bb1dc972f test: cover help-center feedback fallback and view-mode toggle labels
- SidebarHelpCenterIcon: assert the localized fallback renders on typeform
  load error / invalid id, and the embed mounts otherwise
- WorkflowActionsDropdown: assert the active segment keeps its visible label
  in the accessible name and the inactive segment toggles view mode
2026-06-26 11:02:29 -07:00
pythongosssss
9065b845fc fix: address app mode review feedback
- canvasStore: cancel pending RAF chain on rapid linearMode toggles so a
  stale frame can't flash the wrong view mode
- canvasStore.test: advance frames one at a time to actually cover the
  one-frame lag, add rapid-toggle regression test
- WorkflowActionsDropdown: keep the active segment's visible label in its
  accessible name (label-in-name) while preserving the "Workflow actions"
  match
- SidebarHelpCenterIcon: render a localized fallback on typeform load
  error / invalid id instead of an empty popover
- appMode.spec: assert sidebar tabs via menu fixtures instead of class
  selectors; export SidebarTab base and add an appsTab fixture
- appMode.spec: assert app-mode-only center panel after exiting the builder
2026-06-26 10:32:53 -07:00
pythongosssss
61ebcb514d use typeform embed 2026-06-25 13:11:18 -07:00
pythongosssss
b5fd5fd54c remove composable, move to store 2026-06-25 12:48:32 -07:00
pythongosssss
70c2e5e70e remove composable, move to store 2026-06-25 12:43:08 -07:00
pythongosssss
8bd12134b2 fix switching, refactor teleport to two instances 2026-06-25 04:29:41 -07:00
pythongosssss
160d7c7a63 - remove unused key
- fix keboard toggle
- simplify store to composable
- additional tests
2026-06-25 04:06:11 -07:00
pythongosssss
51efcf0424 fix sidenav tab bg color 2026-06-25 03:37:05 -07:00
pythongosssss
0975a7ffbc update app mode bg to have contrast with buttons 2026-06-25 03:25:34 -07:00
pythongosssss
8bebdb3021 refactor toggle animation to use delay on mount instead of flakey teleport 2026-06-25 03:14:16 -07:00
pythongosssss
b8207f2647 fix teleport to ensure re-mount host exists 2026-06-24 13:56:32 -07:00
pythongosssss
787815eb09 feat: update app mode UI and mode toggle
- animate and restyle mode toggle, teleport between modes
- use graph sidebar in app mode, hide elements and force connected
- replace help button in app mode with feedback
- add create buttons to apps tab
2026-06-24 13:32:00 -07:00
198 changed files with 6214 additions and 12085 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -4,6 +4,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { TOURS } from '@/platform/onboarding/onboardingTours'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
@@ -28,6 +29,7 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -70,6 +72,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -104,6 +107,11 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
@@ -535,6 +543,8 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
// An auto-opened tour's blocker would break unrelated tests.
'Comfy.OnboardingCoachmarks.Seen': Object.keys(TOURS),
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
export class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator

View File

@@ -0,0 +1,74 @@
import type { Locator, Page } from '@playwright/test'
export type CoachTour = 'appMode'
const SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
/** Accessible name of each tour's in-app replay (help) button. */
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
appMode: 'Take a tour of App Mode'
}
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
export class OnboardingCoachmarks {
public readonly landing: Locator
public readonly landingStartButton: Locator
public readonly landingSkipButton: Locator
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
public readonly card: Locator
public readonly cardNextButton: Locator
constructor(public readonly page: Page) {
this.landing = page.getByTestId('coach-landing')
this.landingStartButton = this.landing.getByRole('button', {
name: 'Start tutorial'
})
this.landingSkipButton = this.landing.getByRole('button', {
name: 'Skip for now'
})
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
}
/** The tour's in-app help button, which replays it past the seen-flag. */
replayButton(tour: CoachTour): Locator {
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
}
/** The spotlight card while it is showing the given step number. */
cardForStep(step: number): Locator {
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
}
/**
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
* set again) and clicks the tour's replay button, which must be mounted.
*/
async startTour(tour: CoachTour) {
await this.clearSeen()
await this.replayButton(tour).click()
}
private async clearSeen() {
await this.page.evaluate(
async (key) => window.app!.extensionManager.setting.set(key, []),
SEEN_SETTING
)
}
/** An element a tour points at, by its `data-coach-id` anchor. */
coachAnchor(id: string): Locator {
return this.page.locator(`[data-coach-id="${id}"]`)
}
async seen(tour: CoachTour): Promise<boolean> {
const seen = await this.page.evaluate(
async (key) =>
(await window.app!.extensionManager.setting.get(key)) as
| string[]
| undefined,
SEEN_SETTING
)
return !!seen?.includes(tour)
}
}

View File

@@ -42,16 +42,14 @@ export class AppModeHelper {
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
public readonly runButton: Locator
/** The welcome screen shown when app mode has no outputs or no nodes. */
/** The welcome screen shown when app mode has nodes but no outputs. */
public readonly welcome: Locator
/** The empty workflow message shown when no nodes exist. */
public readonly emptyWorkflowText: Locator
/** The "Build app" button shown when nodes exist but no outputs. */
public readonly buildAppButton: Locator
/** The "Back to workflow" button on the welcome screen. */
public readonly backToWorkflowButton: Locator
/** The "Load template" button shown when no nodes exist. */
public readonly loadTemplateButton: Locator
/** The get started page shown when the graph is empty. */
public readonly getStarted: Locator
/** The "Discover all templates" button on the get started page. */
public readonly getStartedDiscoverButton: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
@@ -111,15 +109,10 @@ export class AppModeHelper {
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(
TestIds.appMode.emptyWorkflow
)
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
this.backToWorkflowButton = this.page.getByTestId(
TestIds.appMode.backToWorkflow
)
this.loadTemplateButton = this.page.getByTestId(
TestIds.appMode.loadTemplate
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
this.getStartedDiscoverButton = this.page.getByTestId(
TestIds.appMode.getStartedDiscover
)
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun

View File

@@ -218,10 +218,9 @@ export const TestIds = {
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template',
getStarted: 'linear-get-started',
getStartedDiscover: 'linear-get-started-discover',
arrangePreview: 'linear-arrange-preview',
arrangeNoOutputs: 'linear-arrange-no-outputs',
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
@@ -238,6 +237,9 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`

View File

@@ -0,0 +1,11 @@
import { test as base } from '@playwright/test'
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
export const onboardingFixture = base.extend<{
onboarding: OnboardingCoachmarks
}>({
onboarding: async ({ page }, use) => {
await use(new OnboardingCoachmarks(page))
}
})

View File

@@ -137,6 +137,125 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shares the graph side toolbar, filtered to assets + apps', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode reuses it with only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toBeVisible()
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage
}) => {
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.getByRole('button', { name: 'Workflow actions' })
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
const appActions = comfyPage.page
.getByTestId(TestIds.linear.centerPanel)
.getByRole('button', { name: 'Workflow actions' })
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await expect(toggle).toBeVisible()
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
await expect(toggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const appSegment = comfyPage.page.getByRole('button', {
name: 'Enter app mode'
})
await appSegment.focus()
await appSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// Keyboard activation of the inactive segment must switch mode without the
// keydown bubbling to the trigger and opening the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the toggle's teleport host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
@@ -184,3 +303,45 @@ test.describe('App mode usage', () => {
})
})
})
test.describe('App mode credits', () => {
const API_PRICED_NODE = 'FluxProUltraImageNode'
test('shows the credit breakdown popover for priced nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
await comfyPage.page.evaluate((type) => {
const registered = window.LiteGraph!.registered_node_types[type] as {
nodeData?: { price_badge?: unknown }
}
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
registered.nodeData.price_badge = {
engine: 'jsonata',
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}, API_PRICED_NODE)
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The run/subscribe button flags that the workflow needs credits, even when
// the pill collapses to its icon (kept in the accessible name).
const runButton = comfyPage.appMode.runButton
await expect(runButton).toBeVisible()
await expect(runButton).toHaveAccessibleName(/Uses credits/)
// Hovering the button reveals the per-node credit breakdown.
await runButton.hover()
const breakdown = comfyPage.page.getByRole('list', {
name: 'Credit breakdown by model'
})
await expect(breakdown).toBeVisible()
await expect(breakdown).toContainText('99.9 credits/Run')
await expect(
comfyPage.page.getByText('Requires additional credits')
).toBeVisible()
})
})

View File

@@ -9,14 +9,12 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('Empty workflow text is visible when no nodes', async ({
comfyPage
}) => {
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
@@ -27,35 +25,27 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
test('Get started and build app are hidden when app has outputs', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
})
test('Load template opens template selector', async ({ comfyPage }) => {
test('Discover all templates opens template selector', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.loadTemplateButton.click()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await comfyPage.appMode.getStartedDiscoverButton.click()
await expect(comfyPage.templates.content).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,98 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
import { COACH_IDS } from '@/platform/onboarding/onboardingTours'
const test = mergeTests(comfyPageFixture, onboardingFixture)
// Relies on the default workflow the test server loads (locally: pnpm dev:test)
// — an empty graph would show the welcome screen, not the tour's controls.
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
test.describe('app-mode tour', () => {
test('opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await onboarding.startTour('appMode')
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landing.getByRole('heading')).toHaveText(
'Welcome to Apps'
)
await expect(coach.landingStartButton).toBeFocused()
await coach.landingSkipButton.click()
await expect(coach.landing).toBeHidden()
expect(await coach.seen('appMode')).toBe(true)
})
test('Escape dismisses the welcome landing and marks it seen', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await onboarding.startTour('appMode')
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landingStartButton).toBeFocused()
await comfyPage.page.keyboard.press('Escape')
await expect(coach.landing).toBeHidden()
expect(await coach.seen('appMode')).toBe(true)
})
})
test.describe('coach anchors', () => {
test('every registry id resolves to an element (drift guard)', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.appMode.enterAppModeWithInputs([])
// The assets panel only mounts once its button is clicked; every other
// anchor should already be present in a running app.
for (const id of Object.values(COACH_IDS).filter(
(id) => id !== COACH_IDS.assetsPanel
)) {
await expect(coach.coachAnchor(id)).toBeVisible()
}
await coach.coachAnchor(COACH_IDS.assetsButton).click()
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
})
})
test.describe('spotlight placement', () => {
test('every spotlight card stays fully within the viewport', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
// Read settled placements, not a transient mid-animation frame.
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
// Step 3 (outputs) is the vertically-centred `leftCenter` placement that
// must not slide off the top/bottom edge.
for (const step of [1, 2, 3]) {
const card = coach.cardForStep(step)
await expect(card).toBeVisible()
await expect(card).toBeInViewport({ ratio: 1 })
await coach.cardNextButton.click()
}
// Step 4 (assets button) advances by clicking its target, not Next.
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
await coach.coachAnchor('assets-button').click()
await expect(coach.cardForStep(5)).toBeInViewport({ ratio: 1 })
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -73,6 +73,7 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@floating-ui/vue": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

6
pnpm-lock.yaml generated
View File

@@ -30,6 +30,9 @@ catalogs:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
@@ -465,6 +468,9 @@ importers:
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@floating-ui/vue':
specifier: 'catalog:'
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0

View File

@@ -18,6 +18,7 @@ catalog:
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@floating-ui/vue': ^1.1.11
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

View File

@@ -15,6 +15,7 @@ const IGNORE_PATTERNS = [
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions

View File

@@ -1,5 +1,89 @@
@import '@comfyorg/design-system/css/style.css';
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
--gen-angle must be a registered <angle> so the conic gradient interpolates
instead of jumping between keyframes. */
@property --gen-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes gen-angle-spin {
to {
--gen-angle: 360deg;
}
}
.gen-glow {
position: absolute;
inset: -28%;
border-radius: 50%;
background: conic-gradient(
from var(--gen-angle),
#3b82f63b,
#8b5cf633,
#d946ef2b,
#ec489933,
#f9731629,
#14b8a62e,
#3b82f63b
);
filter: blur(60px);
opacity: 0.34;
animation: gen-angle-spin 12s linear infinite;
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
}
.gen-glow::after {
content: '';
position: absolute;
inset: 8%;
border-radius: 50%;
background: conic-gradient(
from calc(var(--gen-angle) + 120deg),
#3b82f629,
#8b5cf621,
#d946ef1c,
#ec489924,
#f973161a,
#14b8a621,
#3b82f629
);
filter: blur(34px);
opacity: 0.39;
mask-image: radial-gradient(
circle,
#000 0%,
rgb(0 0 0 / 0.62) 36%,
rgb(0 0 0 / 0.22) 50%,
rgb(0 0 0 / 0) 64%
);
}
@media (prefers-reduced-motion: reduce) {
.gen-glow {
animation: none;
}
.genfan-enter-active,
.genfan-leave-active,
.gen-card {
transition: none;
}
}
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
.genfan-enter-active,
.genfan-leave-active {
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
}
.genfan-enter-from,
.genfan-leave-to {
opacity: 0;
}
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,

View File

@@ -28,6 +28,8 @@ const formatNumber = ({
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_ICON = 'icon-[lucide--coins]'
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent

View File

@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AppModeToolbar from './AppModeToolbar.vue'
const appModeState = vi.hoisted(() => ({ enableAppBuilder: true }))
const enterBuilder = vi.hoisted(() => vi.fn())
const nodes = vi.hoisted(() => ({ set: (_value: boolean) => {} }))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
}))
vi.mock('@/stores/appModeStore', async () => {
const { ref } = await import('vue')
const hasNodes = ref(true)
nodes.set = (value: boolean) => {
hasNodes.value = value
}
return { useAppModeStore: () => ({ enterBuilder, hasNodes }) }
})
const BUILD_AN_APP = 'Build an app'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
}
}
})
function setHasNodes(hasNodes: boolean) {
nodes.set(hasNodes)
}
function renderToolbar() {
const user = userEvent.setup()
const result = render(AppModeToolbar, {
global: {
plugins: [i18n],
stubs: {
WorkflowActionsDropdown: true,
Button: {
inheritAttrs: false,
template:
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
}
}
}
})
return { ...result, user }
}
describe('AppModeToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
appModeState.enableAppBuilder = true
setHasNodes(true)
})
it('shows an enabled build button and enters the builder on click', async () => {
setHasNodes(true)
const { user } = renderToolbar()
const button = screen.getByRole('button', { name: BUILD_AN_APP })
expect(button).toBeEnabled()
await user.click(button)
expect(enterBuilder).toHaveBeenCalled()
})
it('disables the build button when there are no nodes', () => {
setHasNodes(false)
renderToolbar()
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
})
it('hides the build button when app building is disabled', () => {
setHasNodes(true)
appModeState.enableAppBuilder = false
renderToolbar()
expect(
screen.queryByRole('button', { name: BUILD_AN_APP })
).not.toBeInTheDocument()
})
})

View File

@@ -1,119 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
)
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
</script>
<template>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
variant="base"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
</Button>
</div>
</template>

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
const canvasState = vi.hoisted(() => ({ linearMode: false }))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({ navigationStack: [] })
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
dispose: vi.fn(),
checkOverflow: vi.fn(),
disposed: { value: false }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { g: { graphNavigation: 'Graph navigation' } }
}
})
function renderBreadcrumb() {
return render(SubgraphBreadcrumb, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
Breadcrumb: true,
Button: true,
SubgraphBreadcrumbItem: true
}
}
})
}
describe('SubgraphBreadcrumb', () => {
beforeEach(() => {
canvasState.linearMode = false
})
it('renders the workflow actions dropdown when not in linear mode', () => {
renderBreadcrumb()
expect(screen.getByTestId('wad')).toBeInTheDocument()
})
it('hides the workflow actions dropdown in linear mode', () => {
canvasState.linearMode = true
renderBreadcrumb()
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
})
})

View File

@@ -14,7 +14,10 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<WorkflowActionsDropdown
v-if="!canvasStore.linearMode"
source="breadcrumb_subgraph_menu_selected"
/>
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const canvasStore = useCanvasStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i :class="CREDITS_ICON" />
</template>
</Tag>
<div :class="textClass">
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useAuthStore } from '@/stores/authStore'
const { textClass, showCreditsOnly } = defineProps<{

View File

@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
const spies = vi.hoisted(() => ({
execute: vi.fn(),
trackUiButtonClicked: vi.fn(),
markAsSeen: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ displayLinearMode: false })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: spies.execute, commands: [] })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
const { ref } = await import('vue')
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
})
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
const { ref } = await import('vue')
return {
useNewMenuItemIndicator: () => ({
hasUnseenItems: ref(true),
markAsSeen: spies.markAsSeen
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { shortcutSuffix: ' ({shortcut})' },
breadcrumbsMenu: {
graph: 'Graph',
app: 'App',
enterNodeGraph: 'Enter node graph',
enterAppMode: 'Enter app mode',
workflowActions: 'Workflow actions'
}
}
}
})
function renderDropdown() {
const user = userEvent.setup()
const result = render(WorkflowActionsDropdown, {
props: { source: 'test' },
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
// Emits update:open on mount so handleOpen's telemetry path is exercised.
DropdownMenuRoot: {
emits: ['update:open'],
mounted() {
this.$emit('update:open', true)
},
template: '<div><slot /></div>'
},
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
WorkflowActionsList: true,
Button: {
inheritAttrs: false,
template:
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
}
}
}
})
return { ...result, user }
}
describe('WorkflowActionsDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the active segment label in its accessible name alongside the actions label', () => {
renderDropdown()
// Graph is the active segment, so its name must contain the visible "Graph"
// label (label-in-name) while still matching the "Workflow actions" trigger.
const active = screen.getByRole('button', { name: /Workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'Graph Workflow actions')
})
it('labels the inactive segment with its switch action only', () => {
renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
})
it('toggles the view mode when the inactive segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not toggle the view mode when the active segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: /Workflow actions/ }))
expect(spies.execute).not.toHaveBeenCalled()
})
it('switches mode when the inactive segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
inactive.focus()
await user.keyboard('{Enter}')
// The keydown guard stops the event bubbling to the trigger, but native
// button activation still switches mode.
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not switch mode when the active segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /Workflow actions/ })
active.focus()
await user.keyboard('{Enter}')
expect(spies.execute).not.toHaveBeenCalled()
})
it('marks new items as seen and reports telemetry when the menu opens', () => {
renderDropdown()
expect(spies.markAsSeen).toHaveBeenCalled()
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'test',
element_group: 'workflow_actions'
})
})
})

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -17,25 +18,67 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
type ViewMode = 'graph' | 'app'
interface ViewModeSegment {
mode: ViewMode
icon: string
label: string
switchLabel: string
switchTooltip: string
active: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
align?: 'start' | 'center' | 'end'
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const canvasStore = useCanvasStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
const toggleShortcut = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
const segments = computed<ViewModeSegment[]>(() => [
{
mode: 'graph',
icon: 'icon-[comfy--workflow]',
label: t('breadcrumbsMenu.graph'),
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
active: !canvasStore.displayLinearMode
},
{
mode: 'app',
icon: 'icon-[lucide--panels-top-left]',
label: t('breadcrumbsMenu.app'),
switchLabel: t('breadcrumbsMenu.enterAppMode'),
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
active: canvasStore.displayLinearMode
}
])
// Inactive segment first (left), active last (right). On mode switch the array
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
const orderedSegments = computed(() =>
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
@@ -46,23 +89,32 @@ 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() {
function switchMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
// The container is the dropdown trigger, so an inactive segment must stop its
// pointer event from bubbling up and opening the menu instead of switching.
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
if (!seg.active) e.stopPropagation()
}
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
// from bubbling to the trigger. The button's native activation still fires
// onSegmentClick to switch mode, so the menu stays closed.
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
}
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
if (seg.active) return
e.stopPropagation()
switchMode()
}
const tooltipPt = {
root: {
style: {
@@ -75,7 +127,7 @@ const tooltipPt = {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
style: { left: '16px' }
}
}
</script>
@@ -86,69 +138,81 @@ const tooltipPt = {
:modal="false"
@update:open="handleOpen"
>
<slot name="button" :has-unseen-items="hasUnseenItems">
<DropdownMenuTrigger as-child>
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
data-testid="view-mode-toggle"
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
>
<Button
v-tooltip.bottom="{
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
<TransitionGroup
tag="div"
move-class="transition-[background-color,color,transform] duration-200"
class="flex items-center gap-1"
>
<i
class="size-4"
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
v-for="seg in orderedSegments"
:key="seg.mode"
v-tooltip.bottom="{
value: seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchTooltip,
showDelay: 300,
hideDelay: 300
hideDelay: 300,
pt: seg.active ? undefined : tooltipPt
}"
variant="secondary"
type="button"
variant="textonly"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
:aria-label="
seg.active
? `${seg.label} ${t('breadcrumbsMenu.workflowActions')}`
: seg.switchLabel
"
:class="
cn(
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
seg.active
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
)
"
@pointerdown="onSegmentPointerDown(seg, $event)"
@keydown="onSegmentKeydown(seg, $event)"
@click="onSegmentClick(seg, $event)"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
<span
v-if="hasUnseenItems"
:class="
cn(
'grid transition-[grid-template-columns,opacity] duration-200',
seg.active
? 'ml-1.5 grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'
)
"
>
<span
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
>
{{ seg.label }}
<i
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</span>
</span>
<span
v-if="seg.active && hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</TransitionGroup>
</div>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:side-offset="8"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>

View File

@@ -471,26 +471,14 @@ const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
getTemplateDescription,
getEffectiveSourceModule,
isAppTemplate,
getBaseThumbnailSrc,
getOverlayThumbnailSrc
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {
if (template.tutorialUrl) {

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
creditsToUsd,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'

View File

@@ -1,6 +1,10 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
export const MODAL_Z_KEY = 'modal'
export const MODAL_Z_BASE = 1700
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
},
beforeUnmount(el) {
ZIndex.clear(el)

View File

@@ -18,8 +18,8 @@
</div>
</div>
</template>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
<template #side-toolbar>
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
</template>
<template v-if="showUI" #side-bar-panel>
<div

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
<span class="truncate" v-text="text" />
</span>
<span
@@ -21,6 +21,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
defineProps<{
text: string
rest?: string

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
/>
<i
aria-hidden="true"
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
const {
nodeDef,
currentQuery,

View File

@@ -0,0 +1,205 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SideToolbar from './SideToolbar.vue'
interface TestTab {
id: string
icon: string
tooltip: string
label: string
title: string
}
const spies = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
toggleAssets: vi.fn()
}))
const state = vi.hoisted(() => ({
linearMode: false,
isMultiUserServer: false,
sidebarTabs: [] as TestTab[],
activeSidebarTab: null as { id: string } | null
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
getSidebarTabs: () => state.sidebarTabs,
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => {
if (key === 'Comfy.Sidebar.Size') return 'large'
if (key === 'Comfy.Sidebar.Location') return 'left'
return 'floating'
}
})
}))
vi.mock('@/stores/userStore', () => ({
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
commands: [
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
]
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ linearMode: state.linearMode, canvas: null })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
type SideToolbarProps = ComponentProps<typeof SideToolbar>
function renderToolbar(props: SideToolbarProps = {}) {
return render(SideToolbar, {
props,
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
stubs: {
ComfyMenuButton: { template: '<div />' },
SidebarTemplatesButton: { template: '<div />' },
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
SidebarHelpCenterIcon: { template: '<div />' },
SidebarSettingsButton: { template: '<div />' },
HelpCenterPopups: { template: '<div />' },
SidebarBottomPanelToggleButton: {
template: '<div data-testid="bottom-panel-toggle" />'
},
SidebarShortcutsToggleButton: {
template: '<div data-testid="shortcuts-toggle" />'
}
}
}
})
}
const assetsTab: TestTab = {
id: 'assets',
icon: 'pi pi-image',
tooltip: 'Assets',
label: 'Assets',
title: 'Assets'
}
const workflowsTab: TestTab = {
id: 'workflows',
icon: 'pi pi-folder',
tooltip: 'Workflows',
label: 'Workflows',
title: 'Workflows'
}
describe('SideToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
state.linearMode = false
state.isMultiUserServer = false
state.sidebarTabs = [assetsTab, workflowsTab]
state.activeSidebarTab = null
})
it('renders only the tabs listed in visibleTabIds', () => {
renderToolbar({ visibleTabIds: ['assets'] })
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Workflows' })
).not.toBeInTheDocument()
})
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
renderToolbar()
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Workflows' })
).toBeInTheDocument()
})
it('marks the toolbar as connected when forceConnected is true', () => {
renderToolbar({ forceConnected: true })
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
})
it('does not mark the toolbar as connected by default', () => {
renderToolbar()
expect(screen.getByTestId('side-toolbar')).not.toHaveClass(
'connected-sidebar'
)
})
it('shows the shortcuts and bottom panel toggles when not in linear mode', () => {
state.linearMode = false
renderToolbar()
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
})
it('hides the shortcuts and bottom panel toggles in linear mode', () => {
state.linearMode = true
renderToolbar()
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
})
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
const user = userEvent.setup()
renderToolbar({ visibleTabIds: ['assets'] })
await user.click(screen.getByRole('button', { name: 'Assets' }))
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
expect(spies.toggleAssets).toHaveBeenCalled()
})
it('renders the logout icon only on a multi-user server', () => {
const { unmount } = renderToolbar()
expect(screen.queryByTestId('logout')).not.toBeInTheDocument()
unmount()
state.isMultiUserServer = true
renderToolbar()
expect(screen.getByTestId('logout')).toBeInTheDocument()
})
})

View File

@@ -23,6 +23,7 @@
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
v-coachmark="tab.id === 'assets' ? 'assets-button' : undefined"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
@@ -42,8 +43,14 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarBottomPanelToggleButton
v-if="!isCloud && !canvasStore.linearMode"
:is-small="isSmall"
/>
<SidebarShortcutsToggleButton
v-if="!canvasStore.linearMode"
:is-small="isSmall"
/>
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
@@ -73,6 +80,7 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { vCoachmark } from '@/platform/onboarding/vCoachmark'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -89,6 +97,11 @@ import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const { visibleTabIds, forceConnected = false } = defineProps<{
visibleTabIds?: string[]
forceConnected?: boolean
}>()
const NightlySurveyController =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -115,12 +128,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isConnected = computed(
() =>
forceConnected ||
selectedTab.value ||
isOverflowing.value ||
sidebarStyle.value === 'connected'
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const tabs = computed(() => {
const all = workspaceStore.getSidebarTabs()
return visibleTabIds
? all.filter((tab) => visibleTabIds.includes(tab.id))
: all
})
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**

View File

@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
const typeformState = vi.hoisted(() => ({
typeformError: false,
isValidTypeformId: true,
typeformId: 'jmmzmlKw'
}))
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
const { computed } = await import('vue')
return {
useTypeformEmbed: () => ({
typeformError: computed(() => typeformState.typeformError),
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
typeformId: computed(() => typeformState.typeformId)
})
}
})
vi.mock('@/composables/useHelpCenter', async () => {
const { ref } = await import('vue')
return {
useHelpCenter: () => ({
shouldShowRedDot: ref(false),
toggleHelpCenter: vi.fn()
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: () => 'left' })
}))
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
const { computed } = await import('vue')
return {
useCanvasStore: () => ({ linearMode: computed(() => true) })
}
})
const FEEDBACK_LOAD_ERROR =
'Failed to load feedback form. Please try again later.'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
menu: { help: 'Help' },
sideToolbar: { helpCenter: 'Help Center' },
linearMode: {
giveFeedback: 'Give feedback',
feedbackLoadError: FEEDBACK_LOAD_ERROR
}
}
}
})
function renderIcon() {
return render(SidebarHelpCenterIcon, {
props: { isSmall: false },
global: {
plugins: [i18n],
stubs: {
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
SidebarIcon: true
}
}
})
}
describe('SidebarHelpCenterIcon', () => {
beforeEach(() => {
typeformState.typeformError = false
typeformState.isValidTypeformId = true
})
it('mounts the Typeform embed container when the id is valid and loads', () => {
const { container } = renderIcon()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
})
it('shows the localized fallback instead of the embed when loading fails', () => {
typeformState.typeformError = true
const { container } = renderIcon()
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).toBeNull()
})
it('shows the localized fallback when the form id is invalid', () => {
typeformState.isValidTypeformId = false
const { container } = renderIcon()
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).toBeNull()
})
})

View File

@@ -1,5 +1,34 @@
<template>
<Popover
v-if="linearMode"
:side="sidebarOnLeft ? 'right' : 'left'"
:side-offset="8"
>
<template #button>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('linearMode.giveFeedback')"
:is-small="isSmall"
/>
</template>
<div
v-if="typeformError || !isValidTypeformId"
class="text-danger p-4 text-sm"
>
{{ $t('linearMode.feedbackLoadError') }}
</div>
<div
v-else
ref="feedbackRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
/>
</Popover>
<SidebarIcon
v-else
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
@@ -13,13 +42,34 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SidebarIcon from './SidebarIcon.vue'
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
defineProps<{
isSmall: boolean
}>()
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
const { linearMode } = storeToRefs(useCanvasStore())
const settingStore = useSettingStore()
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
feedbackRef,
APP_MODE_FEEDBACK_TYPEFORM_ID
)
</script>

View File

@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AppsSidebarTab from './AppsSidebarTab.vue'
const execute = vi.hoisted(() => vi.fn())
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { beta: 'Beta' },
linearMode: {
appModeToolbar: {
apps: 'Apps',
create: 'Create',
createApp: 'Create app',
appsEmptyMessage: 'No apps yet',
appsEmptyMessageAction: 'Create one to get started'
}
}
}
}
})
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
stubs: {
BaseWorkflowsSidebarTab: {
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
},
Button: {
inheritAttrs: false,
template:
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
},
NoResultsPlaceholder: {
props: ['buttonLabel'],
emits: ['action'],
template:
'<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
}
}
}
})
return { ...result, user }
}
describe('AppsSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the create action only when there are results', () => {
const { unmount } = renderTab({ hasResults: false })
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
unmount()
renderTab({ hasResults: true })
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
})
it('runs the new-workflow command when the create action is clicked', async () => {
const { user } = renderTab({ hasResults: true })
await user.click(screen.getByRole('button', { name: 'Create' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
it('runs the new-workflow command from the empty-state action', async () => {
const { user } = renderTab({ hasResults: false })
await user.click(screen.getByRole('button', { name: 'Create app' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
})

View File

@@ -13,18 +13,26 @@
{{ $t('g.beta') }}
</span>
</template>
<template #header-actions="{ hasResults }">
<Button
v-if="hasResults"
variant="secondary"
size="md"
:aria-label="$t('linearMode.appModeToolbar.create')"
@click="createApp"
>
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
{{ $t('linearMode.appModeToolbar.create') }}
</Button>
</template>
<template #empty-state>
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
button-icon="icon-[lucide--hammer]"
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
@action="enterAppMode"
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
button-icon="icon-[lucide--plus]"
:button-label="$t('linearMode.appModeToolbar.createApp')"
@action="createApp"
/>
</template>
</BaseWorkflowsSidebarTab>
@@ -33,16 +41,17 @@
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import { useAppMode } from '@/composables/useAppMode'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
const { isAppMode, setMode } = useAppMode()
const commandStore = useCommandStore()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function enterAppMode() {
setMode('app')
function createApp() {
void commandStore.execute('Comfy.NewBlankWorkflow')
}
</script>

View File

@@ -30,6 +30,10 @@
"
/>
</Button>
<slot
name="header-actions"
:has-results="filteredPersistedWorkflows.length > 0"
/>
</template>
<template #header>
<SidebarTopArea>

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { cn } from '@comfyorg/tailwind-utils'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { active = true } = defineProps<{
dataTfWidget: string
active?: boolean
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -25,7 +25,8 @@ export const buttonVariants = cva({
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +59,8 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'gradient',
'brand-yellow'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -223,7 +224,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -16,14 +16,12 @@ import {
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
appendQuarantine,
flushProxyWidgetMigration,
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -181,33 +179,6 @@ describe('flushProxyWidgetMigration', () => {
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const source = new LGraphNode('CLIPTextEncode')
const sourceSlot = source.addInput('text', 'STRING')
sourceSlot.widget = { name: 'text' }
source.addWidget('text', 'text', 'nested value', () => {})
innerSubgraph.add(source)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -269,41 +240,6 @@ describe('flushProxyWidgetMigration', () => {
).toBe('renamed_from_sidepanel')
})
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.type = undefined as never
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
).toBe('number')
})
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.type = undefined as never
slot.widget = { name: 'seed' }
const widget = n.addWidget('number', 'seed', 0, () => {})
widget.type = undefined as never
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
).toBe('*')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
@@ -392,88 +328,6 @@ describe('flushProxyWidgetMigration', () => {
expect(getPromotedInputValue(host, 'value')).toBe(11)
})
it('uses the primitive title as the promoted input name when it was renamed', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.title = 'Batch Size'
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(
host.inputs.find((input) => input.name === 'Batch Size')
).toBeDefined()
})
it('skips a stale primitive bypass marker when the host input is absent', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'deleted_input'
}
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const slot = targets[0].inputs[0]
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
})
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'plain'
}
host.addInput('plain', 'INT')
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [12]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'missingSubgraphInput'
})
])
})
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.properties = {
proxyBypassedToSubgraphInput: 'plain'
}
host.addInput('plain', 'INT')
host.addInput('plain', 'INT')
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [12]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('quarantines an unlinked primitive node with no fan-out', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
@@ -492,64 +346,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('quarantines primitive cohorts that disagree on source widget name', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'other']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
}),
expect.objectContaining({
originalEntry: [String(primitive.id), 'other'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines duplicate primitive entries with no fan-out targets', () => {
const host = buildHost()
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
host.subgraph.add(primitive)
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps the target default when the primitive source widget has no value', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
primitive.widgets = []
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(getPromotedInputValue(host, 'value')).toBe(0)
})
it('quarantines all cohort entries when a target slot type is incompatible', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
@@ -570,73 +366,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('quarantines primitive repair when the target slot disappeared', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
targets[0].inputs = []
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines primitive repair when the target node id is stale', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
const linkId = primitive.outputs[0].links?.[0]
if (!linkId) throw new Error('Missing primitive link')
const link = host.subgraph.links.get(linkId)
if (!link) throw new Error('Missing primitive link record')
link.target_id = toNodeId(999_999)
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1
})
const linkId = primitive.outputs[0].links?.[0]
if (!linkId) throw new Error('Missing primitive link')
const link = host.subgraph.links.get(linkId)
if (!link) throw new Error('Missing primitive link record')
link.target_id = UNASSIGNED_NODE_ID
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
@@ -843,22 +572,6 @@ describe('flushProxyWidgetMigration', () => {
])
})
it('does not preserve non-widget host values on quarantine rows', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [null]
})
expect(readHostQuarantine(host)).toEqual([
expect.not.objectContaining({
hostValue: expect.anything()
})
])
})
it('round-trips appended entries via the public read helper', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
@@ -889,14 +602,6 @@ describe('flushProxyWidgetMigration', () => {
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
it('ignores empty quarantine append requests', () => {
const host = buildHost()
appendQuarantine(host, [])
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('idempotency', () => {
@@ -1119,22 +824,6 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('strips nested legacy prefixes from widget name', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'111: 222: seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: '222'
})
})
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')

View File

@@ -1,179 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import {
inputForWidget,
promotedInputSource,
promotedInputWidget,
promotedInputWidgets,
widgetPromotedSource
} from './promotedInputWidget'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
const mocks = vi.hoisted(() => ({
widgets: new Map<string, Record<string, unknown>>(),
setValue: vi.fn(),
resolveSubgraphInputTarget: vi.fn()
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getWidget: (id: string) => mocks.widgets.get(id),
setValue: mocks.setValue
})
}))
vi.mock('./resolveSubgraphInputTarget', () => ({
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
}))
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
return {
name: 'prompt',
type: 'STRING',
label: 'Prompt',
...overrides
} as INodeInputSlot
}
function node(overrides: Record<string, unknown> = {}): LGraphNode {
return {
inputs: [],
isSubgraphNode: () => true,
getSlotFromWidget: vi.fn(),
...overrides
} as unknown as LGraphNode
}
describe('promotedInputWidget helpers', () => {
beforeEach(() => {
mocks.widgets.clear()
mocks.setValue.mockClear()
mocks.resolveSubgraphInputTarget.mockReset()
})
it('resolves promoted input sources only for widget-backed inputs', () => {
const graphNode = node()
mocks.resolveSubgraphInputTarget.mockReturnValue({
nodeId: '12',
widgetName: 'prompt'
})
expect(promotedInputSource(graphNode, input())).toBeUndefined()
expect(
promotedInputSource(
graphNode,
input({ widgetId: 'graph:12:prompt' as WidgetId })
)
).toEqual({
nodeId: '12',
widgetName: 'prompt'
})
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
})
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
const widget = { name: 'prompt' } as IBaseWidget
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
mocks.resolveSubgraphInputTarget.mockReturnValue({
nodeId: '12',
widgetName: 'prompt'
})
expect(
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
).toBeUndefined()
expect(
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
).toBeUndefined()
expect(
widgetPromotedSource(
node({ getSlotFromWidget: () => backingInput }),
widget
)
).toEqual({
nodeId: '12',
widgetName: 'prompt'
})
})
it('projects store-backed widget fields with input fallbacks', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const widget = promotedInputWidget(input({ widgetId }))
expect(widget?.name).toBe('prompt')
expect(widget?.label).toBe('Prompt')
expect(widget?.y).toBe(0)
expect(widget?.type).toBe('text')
expect(widget?.options).toEqual({})
expect(widget?.value).toBeUndefined()
widget!.label = 'Ignored'
widget!.y = 12
widget!.value = 'next'
widget!.callback?.('callback')
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
})
it('projects live widget store fields and mutates store state', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const state = {
name: 'store-name',
label: 'Store Label',
y: 42,
type: 'combo',
options: { values: ['a'] },
value: 'a'
}
mocks.widgets.set(widgetId, state)
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
expect(widget?.name).toBe('store-name')
expect(widget?.label).toBe('Store Label')
expect(widget?.y).toBe(42)
expect(widget?.type).toBe('combo')
expect(widget?.options).toEqual({ values: ['a'] })
expect(widget?.value).toBe('a')
widget!.label = 'New Label'
widget!.y = 52
expect(state.label).toBe('New Label')
expect(state.y).toBe(52)
})
it('returns null for non-promoted inputs and filters projected widget lists', () => {
const widgetId = 'graph:12:prompt' as WidgetId
const graphNode = node({
inputs: [input(), input({ widgetId })]
})
expect(promotedInputWidget(input())).toBeNull()
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
})
it('returns undefined for null stored values', () => {
const widgetId = 'graph:12:prompt' as WidgetId
mocks.widgets.set(widgetId, { value: null })
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
})
it('delegates input lookup to the graph node', () => {
const widget = { name: 'prompt' } as IBaseWidget
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
const graphNode = node({
getSlotFromWidget: vi.fn(() => backingInput)
})
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
})
})

View File

@@ -15,10 +15,6 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import type { WidgetId } from '@/types/widgetId'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { toNodeId } from '@/types/nodeId'
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
@@ -55,37 +51,19 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
}))
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
vi.mock('@sentry/vue', () => ({
addBreadcrumb: addBreadcrumbMock
}))
const mockNavigation = vi.hoisted(() => ({
stack: [] as Subgraph[]
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({
navigationStack: mockNavigation.stack
})
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
addWidgetPromotionOptions,
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder,
tryToggleWidgetPromotion
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -96,6 +74,11 @@ function widget(
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
/**
* Builds a host SubgraphNode whose subgraph contains two source nodes that
* share a widget name (`text`), then promotes both — forcing the second
* promotion to be disambiguated to `text_1`.
*/
function buildDuplicateNamePromotion() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -119,11 +102,6 @@ function buildDuplicateNamePromotion() {
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
}
function setupNavigation(host: SubgraphNode) {
host.subgraph.rootGraph.add(host)
mockNavigation.stack = [host.subgraph]
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -325,284 +303,6 @@ describe('getPromotableWidgets', () => {
})
})
describe('widget promotion actions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
addBreadcrumbMock.mockReset()
mockNavigation.stack = []
})
function setupPromotableWidget() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
setupNavigation(host)
const node = new LGraphNode('Prompt')
subgraph.add(node)
const input = node.addInput('text', 'STRING')
input.label = 'Prompt text'
const callback = vi.fn()
const textWidget = node.addWidget('text', 'text', 'value', callback)
textWidget.label = 'Prompt'
input.widget = { name: textWidget.name }
return { host, node, textWidget, callback }
}
it('adds a promote menu option and runs the widget callback after promotion', () => {
const { host, node, textWidget, callback } = setupPromotableWidget()
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
const menuCallback = options[0]?.callback as
| ((...args: unknown[]) => unknown)
| undefined
void menuCallback?.(null, undefined, undefined)
expect(options[0]?.content).toContain('Prompt')
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
expect(callback).toHaveBeenCalledWith('value')
})
it('adds an unpromote menu option when the widget is already promoted', () => {
const { host, node, textWidget, callback } = setupPromotableWidget()
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
true
)
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
const menuCallback = options[0]?.callback as
| ((...args: unknown[]) => unknown)
| undefined
void menuCallback?.(null, undefined, undefined)
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
expect(callback).toHaveBeenCalledWith('value')
})
it('reports outside-subgraph promotion attempts through the toast store', () => {
const node = new LGraphNode('Prompt')
const textWidget = node.addWidget('text', 'text', 'value', () => {})
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
expect(useToastStore().messagesToAdd).toHaveLength(1)
expect(options).toHaveLength(1)
})
it('toggles promotion for the widget under the canvas pointer', () => {
const { host, node, textWidget } = setupPromotableWidget()
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
graph_mouse: [10, 20],
visible_nodes: [node],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => node)
}
})
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
useCanvasStore().canvas = canvas
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
})
it('leaves state unchanged when toggle has no node or widget target', () => {
const { host, node, textWidget } = setupPromotableWidget()
useCanvasStore().canvas = fromPartial<
ReturnType<typeof useCanvasStore>['canvas']
>({
graph_mouse: [0, 0],
visible_nodes: [],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => null)
}
})
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
useCanvasStore().canvas = fromPartial<
ReturnType<typeof useCanvasStore>['canvas']
>({
graph_mouse: [0, 0],
visible_nodes: [node],
setDirty: vi.fn(),
graph: {
getNodeOnPos: vi.fn(() => node)
}
})
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
tryToggleWidgetPromotion()
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
false
)
})
it('records a breadcrumb when value promotion has no source slot', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const node = new LGraphNode('LooseWidgetNode')
subgraph.add(node)
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
promoteWidget(node, looseWidget, [host])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warning',
message: expect.stringContaining('missingSourceSlot')
})
)
})
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const partialNode = {
id: toNodeId(123),
title: 'Partial',
type: 'Partial'
}
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
expect(host.subgraph.inputs).toEqual([])
expect(addBreadcrumbMock).not.toHaveBeenCalled()
})
it('uses the widget name in menu text when label is absent', () => {
const { node, textWidget } = setupPromotableWidget()
textWidget.label = undefined
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
addWidgetPromotionOptions(options, textWidget, node)
expect(options[0]?.content).toContain('text')
})
})
describe('preview promotion actions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
addBreadcrumbMock.mockReset()
mockNavigation.stack = []
})
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
sourceNodeId: previewNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(
isWidgetPromotedOnSubgraphNode(
host,
{
sourceNodeId: previewNode.id,
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
},
previewWidget
)
).toBe(true)
expect(
isWidgetPromotedOnSubgraphNode(
host,
{
sourceNodeId: previewNode.id,
sourceWidgetName: 'other'
},
previewWidget
)
).toBe(false)
})
it('deduplicates preview exposures when the same preview is promoted twice', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
promoteWidget(previewNode, previewWidget, [host])
promoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toHaveLength(1)
})
it('demotes preview exposures when no linked value promotion exists', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
promoteWidget(previewNode, previewWidget, [host])
demoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toEqual([])
})
it('leaves unexposed preview widgets unchanged when demoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(previewNode)
const previewWidget = widget({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
serialize: false,
type: 'preview'
})
demoteWidget(previewNode, previewWidget, [host])
expect(
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
).toEqual([])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
})
)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -646,49 +346,6 @@ describe('promoteRecommendedWidgets', () => {
)
})
it('keeps value promotion idempotent when the widget is already linked', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Prompt')
const input = interiorNode.addInput('text', 'STRING')
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
input.widget = { name: textWidget.name }
subgraph.add(interiorNode)
expect(
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
.ok
).toBe(true)
expect(
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
.ok
).toBe(true)
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
})
it('seeds outer promoted widget state from a nested promoted input', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
writePromotedInputValue(innerHost, 'text', 'inner value')
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, 'text')
).ok
).toBe(true)
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
'inner value'
)
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -757,24 +414,6 @@ describe('promoteRecommendedWidgets', () => {
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('records a breadcrumb when a recommended value widget has no source slot', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('CLIPTextEncode')
interiorNode.type = 'CLIPTextEncode'
interiorNode.addWidget('text', 'text', '', () => {})
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
level: 'warning',
message: expect.stringContaining('missingSourceSlot')
})
)
})
})
describe('autoExposeKnownPreviewNodes', () => {
@@ -843,52 +482,6 @@ describe('autoExposeKnownPreviewNodes', () => {
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
})
it('defers preview discovery for nodes without eager preview widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('DeferredPreview')
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
subgraph.add(interiorNode)
try {
autoExposeKnownPreviewNodes(subgraphNode)
rafCallbacks[0]?.(0)
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
const previewWidget = interiorNode.addWidget(
'preview' as Parameters<typeof interiorNode.addWidget>[0],
'preview',
'',
() => {}
)
previewWidget.serialize = false
previewWidget.type = 'preview'
updateCallback?.()
expect(updatePreviewsMock).toHaveBeenCalledWith(
interiorNode,
expect.any(Function)
)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toContainEqual({
name: 'preview',
sourceNodeId: String(interiorNode.id),
sourcePreviewName: 'preview'
})
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
})
describe('hasUnpromotedWidgets', () => {
@@ -1080,25 +673,6 @@ describe('reorderSubgraphInputsByName', () => {
])
})
it('leaves unordered names after explicitly ordered inputs', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first',
'third'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -1194,33 +768,6 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
'first value'
])
})
it('appends promoted inputs that are absent from the widget order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputsByWidgetOrder(host, [
promotedWidgetRef(host, 'second')
])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first'
])
})
})
describe('demoteWidget — axiomatic projection retraction', () => {
@@ -1251,23 +798,6 @@ describe('demoteWidget — axiomatic projection retraction', () => {
return { host, interiorNode, interiorWidget }
}
it('runs as a no-op for an unpromoted non-preview widget', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
host.subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
demoteWidget(interiorNode, widget, [host])
expect(host.subgraph.inputs).toEqual([])
expect(addBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Demoted widget "value"')
})
)
})
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
@@ -1413,54 +943,4 @@ describe('disambiguated nested promotion identity', () => {
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
})
it('promotes a widget whose source widget state is missing', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
interiorInput.widgetId = 'missing-widget-state' as WidgetId
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
})
it('keeps plain inputs after ordered promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'plain', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByWidgetOrder(host, [
{ widgetId: 'missing-widget-state' as WidgetId }
])
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
})
it('falls back to append order when promoted input links are stale', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
const promotedInput = host.subgraph.inputs[0]
const linkId = promotedInput.linkIds[0]
host.subgraph.links.delete(linkId)
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
})
})

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveInputType } from './dynamicTypes'
describe('resolveInputType', () => {
it('splits concrete comma-delimited input types', () => {
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
'MODEL',
'CLIP'
])
})
it('resolves match-type templates from allowed types', () => {
expect(
resolveInputType({
type: 'COMFY_MATCHTYPE_V3',
template: {
allowed_types: 'IMAGE,MASK',
template_id: 'image'
}
} as never)
).toEqual(['IMAGE', 'MASK'])
})
it('returns an empty type list for invalid match-type templates', () => {
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
[]
)
})
it('resolves autogrow templates from required and optional inputs', () => {
expect(
resolveInputType({
type: 'COMFY_AUTOGROW_V3',
template: {
input: {
required: {
image: ['IMAGE', {}]
},
optional: {
mask: ['MASK,IMAGE', {}]
}
}
}
} as never)
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
})
it('returns an empty type list for invalid autogrow templates', () => {
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
})
})

View File

@@ -1,19 +1,13 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { app } from '@/scripts/app'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLitegraphService } from '@/services/litegraphService'
import type { HasInitialMinSize } from '@/services/litegraphService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createTestingPinia())
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
type TestAutogrowNode = LGraphNode & {
comfyDynamic: { autogrow: Record<string, unknown> }
@@ -21,13 +15,6 @@ type TestAutogrowNode = LGraphNode & {
const { addNodeInput } = useLitegraphService()
beforeEach(() => {
vi.clearAllMocks()
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 0
})
function nextTick() {
return new Promise<void>((r) => requestAnimationFrame(() => r()))
}
@@ -69,23 +56,6 @@ function addAutogrow(node: LGraphNode, template: unknown) {
})
)
}
function addMatchType(
node: LGraphNode,
name: string,
allowedTypes = '*',
templateId = 'a'
) {
addNodeInput(
node,
transformInputSpecV1ToV2(
[
'COMFY_MATCHTYPE_V3',
{ template: { allowed_types: allowedTypes, template_id: templateId } }
],
{ name, isOptional: false }
)
)
}
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
const node2 = testNode()
node2.addOutput('out', '*')
@@ -146,312 +116,7 @@ describe('Dynamic Combos', () => {
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
test('throws for malformed dynamic combo specs before creating a widget', () => {
const node = testNode()
const comboApp = { widgets: { COMBO: vi.fn() } } as unknown as Parameters<
typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3
>[3]
expect(() =>
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
node,
'bad',
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
comboApp
)
).toThrow('invalid DynamicCombo spec')
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
})
test('clears grouped widgets when selection becomes empty', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
node.widgets[0].value = '1'
const onRemove = vi.fn()
node.widgets[1].onRemove = onRemove
node.widgets[0].value = undefined
expect(onRemove).toHaveBeenCalled()
expect(node.widgets).toHaveLength(1)
})
test('deletes widget state when removing grouped dynamic widgets', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addDynamicCombo(node, [['INT'], ['STRING']])
const childWidget = node.widgets[1]
const childWidgetId = childWidget.widgetId
if (!childWidgetId) throw new Error('Missing child widget id')
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
node.widgets[0].value = undefined
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
})
test('preserves an existing dynamic input link when refreshing a selection', () => {
const graph = new LGraph()
const node = testNode()
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
graph.add(node)
addDynamicCombo(node, [['IMAGE'], ['STRING']])
node.widgets[0].value = '0'
connectInput(node, 1, graph)
const linkId = node.inputs[1].link
expect(linkId).not.toBeNull()
onConnectionsChange.mockClear()
node.widgets[0].value = '0'
expect(node.inputs[1].link).toBe(linkId)
expect(graph.links[linkId!].target_slot).toBe(1)
expect(onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
1,
true,
graph.links[linkId!],
node.inputs[1]
)
})
test('throws if the backing widgets array disappears during update', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets[0]
node.widgets = undefined as unknown as typeof node.widgets
expect(() => {
controller.value = '1'
}).toThrow('Not Reachable')
})
test('throws when the dynamic controller widget is missing during update', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets[0]
node.widgets = node.widgets.slice(1)
expect(() => {
controller.value = '1'
}).toThrow("Dynamic widget doesn't exist on node")
})
test('throws when input-only dynamic sockets have no insertion point', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['IMAGE']])
const controller = node.widgets[0]
node.inputs = []
expect(() => {
controller.value = '1'
}).toThrow('Failed to find input socket for 0')
})
test('updates dynamic inputs without requiring a graph', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['IMAGE']])
node.widgets[0].value = '1'
expect(node.inputs[1].type).toBe('IMAGE')
})
test('reads dynamic combo values from widget state when available', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addDynamicCombo(node, [['INT'], ['STRING']])
const controller = node.widgets[0]
const controllerId = controller.widgetId
if (!controllerId) throw new Error('Missing controller widget id')
controller.value = '1'
useWidgetValueStore().setValue(controllerId, '0')
expect(controller.value).toBe('0')
})
})
describe('Dynamic input dispatch', () => {
test('returns false for unknown dynamic input types', () => {
const node = testNode()
expect(
applyDynamicInputs(node, {
name: 'plain',
type: 'STRING',
isOptional: false
})
).toBe(false)
})
test('returns true after applying a known dynamic input type', () => {
const node = testNode()
expect(
applyDynamicInputs(
node,
transformInputSpecV1ToV2(
[
'COMFY_AUTOGROW_V3',
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
],
{ name: 'grow', isOptional: false }
)
)
).toBe(true)
})
test('throws when an autogrow input spec is malformed', () => {
const node = testNode()
const inputSpec = {
name: 'bad',
type: 'COMFY_AUTOGROW_V3'
} as InputSpecV2
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
})
test('ignores malformed match type specs', () => {
const node = testNode()
expect(
applyDynamicInputs(node, {
name: 'bad',
type: 'COMFY_MATCHTYPE_V3',
isOptional: false
})
).toBe(true)
expect(node.inputs).toHaveLength(0)
})
})
describe('MatchType inputs', () => {
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
const node = testNode()
node.constructor.nodeData = {
name: 'testnode',
output_matchtypes: outputMatchTypes
} as typeof node.constructor.nodeData
node.addOutput('out', '*')
graph.add(node)
addMatchType(node, 'on_true')
addMatchType(node, 'on_false')
return node
}
function createSourceNode(graph: LGraph, type: string) {
const node = testNode()
node.addOutput('out', type)
graph.add(node)
return node
}
test('ignores match type notifications outside registered inputs', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
node.addInput('plain', 'STRING')
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
expect(node.outputs[0].type).toBe('*')
})
test('uses wildcard types for stale match type links', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
node.inputs[0].link = toLinkId(999)
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
expect(node.outputs[0].type).toBe('*')
})
test('leaves unmatched output groups unchanged', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph, ['other'])
const source = createSourceNode(graph, 'IMAGE')
source.connect(0, node, 0)
expect(node.outputs[0].type).toBe('*')
})
test('throws when match group input constraints cannot overlap', () => {
const graph = new LGraph()
const node = testNode()
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(() => 1)
node.constructor.nodeData = {
name: 'testnode',
output_matchtypes: ['a']
} as typeof node.constructor.nodeData
node.addOutput('out', '*')
graph.add(node)
addMatchType(node, 'image', 'IMAGE')
addMatchType(node, 'latent', 'LATENT')
const source = createSourceNode(graph, 'IMAGE')
try {
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
test('disconnects downstream links when a match type output narrows', () => {
const graph = new LGraph()
const node = createMatchTypeNode(graph)
const downstream = testNode()
downstream.addInput('latent', 'LATENT')
downstream.onConnectionsChange = vi.fn()
graph.add(downstream)
node.connect(0, downstream, 0)
const source = createSourceNode(graph, 'IMAGE')
source.connect(0, node, 0)
expect(downstream.inputs[0].link).toBeNull()
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
0,
false,
expect.anything(),
downstream.inputs[0]
)
})
test('ignores deferred match type refresh after the input is removed', () => {
const graph = new LGraph()
const node = testNode()
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
graph.add(node)
try {
addMatchType(node, 'removed')
node.inputs.pop()
rafCallbacks[0]?.(0)
expect(node.inputs).toHaveLength(0)
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }
test('Can name by prefix', () => {
@@ -497,259 +162,6 @@ describe('Autogrow', () => {
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
})
test('ignores autogrow notifications that cannot affect a known input group', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
const inputCount = node.inputs.length
const unknownInput = node.addInput('outside.0', 'IMAGE')
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
node.onConnectionsChange?.(
LiteGraph.INPUT,
99,
true,
null,
fromAny<
Parameters<NonNullable<typeof node.onConnectionsChange>>[4],
unknown
>(undefined)
)
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
expect(node.inputs).toHaveLength(inputCount + 1)
})
test('does not grow autogrow inputs when connection metadata is missing', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
expect(node.inputs).toHaveLength(2)
})
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
await nextTick()
expect(node.inputs).toHaveLength(3)
})
test('restores a configure-time autogrow widget shim', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
connectInput(node, 1, graph)
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
})
test('draws configure-time autogrow shim text from the input name', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
connectInput(node, 1, graph)
const shim = node.widgets.find((widget) => widget.name === '0.test1')
if (!shim?.draw) throw new Error('Missing shim widget')
node.inputs[1].label = undefined
const ctx = fromAny<CanvasRenderingContext2D, unknown>({
save: vi.fn(),
fillText: vi.fn(),
restore: vi.fn()
})
shim.draw(ctx, node, 100, 10, 20)
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
})
test('keeps an existing configure-time autogrow widget shim', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.inputs[1].widget = { name: node.inputs[1].name }
node.widgets.push({
name: node.inputs[1].name,
type: 'shim',
y: 0,
options: {},
serialize: false,
draw: vi.fn()
})
fromAny<{ configuringGraphLevel: number }, unknown>(
app
).configuringGraphLevel = 1
connectInput(node, 1, graph)
expect(
node.widgets.filter((widget) => widget.name === '0.test1')
).toHaveLength(1)
})
test('defers disconnect handling during an input swap', () => {
const graph = new LGraph()
const node = testNode()
const rafCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
rafCallbacks.push(callback)
return rafCallbacks.length
})
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
try {
connectInput(node, 0, graph)
node.disconnectInput(0)
expect(node.inputs).toHaveLength(2)
expect(rafCallbacks).toHaveLength(2)
} finally {
requestAnimationFrameSpy.mockRestore()
}
})
test('stops cleanup for uneven multi-input autogrow groups', async () => {
const graph = new LGraph()
const node = testNode()
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
graph.add(node)
addAutogrow(node, {
min: 1,
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
})
node.inputs.pop()
try {
node.onConnectionsChange?.(
LiteGraph.INPUT,
0,
false,
null,
node.inputs[0]
)
await nextTick()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to group multi-input autogrow inputs'
)
} finally {
consoleErrorSpy.mockRestore()
}
})
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.test0',
'0.test1'
])
})
test('ignores named autogrow input names outside the configured list', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
const unknownInput = node.addInput('0.c', 'IMAGE')
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length - 1,
false,
null,
unknownInput
)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.a',
'0.b',
'0.c'
])
})
test('ignores autogrow input names without numeric ordinals', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
const unknownInput = node.addInput('0.testx', 'IMAGE')
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length - 1,
false,
null,
unknownInput
)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.test0',
'0.test1',
'0.testx'
])
})
test('marks optional autogrow inputs as optional after required inputs', () => {
const node = testNode()
addAutogrow(node, {
min: 1,
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
}
})
expect(node.inputs.map((input) => input.name)).toEqual([
'0.image0',
'0.mask0',
'0.image1',
'0.mask1'
])
expect(node.inputs.map((input) => input.type)).toEqual([
'IMAGE',
'MASK',
'IMAGE',
'MASK'
])
})
test('Removing connections decreases to min + 1', async () => {
const graph = new LGraph()
const node = testNode()
@@ -846,42 +258,6 @@ describe('Autogrow', () => {
expect(vid0Link).not.toBeNull()
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
})
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, {
min: 1,
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
})
connectInput(node, 2, graph)
await nextTick()
expect(node.inputs).toHaveLength(6)
const removedWidgetNames = ['0.image2', '0.mask2']
const onRemove = vi.fn()
for (const widget of node.widgets.filter((widget) =>
removedWidgetNames.includes(widget.name)
)) {
widget.onRemove = onRemove
}
node.disconnectInput(2)
await nextTick()
expect(node.inputs.map((input) => input.name)).toEqual([
'0.image0',
'0.mask0',
'0.image1',
'0.mask1'
])
expect(onRemove).toHaveBeenCalledTimes(2)
expect(
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
).toBe(false)
})
test('Can deserialize a complex node', async () => {
const graph = new LGraph()
const node = testNode()

View File

@@ -127,45 +127,4 @@ describe('MatchType during configure', () => {
expect(switchNode.inputs[1].link).not.toBeNull()
expect(switchNode.outputs[0].type).toBe('IMAGE')
})
test('keeps compatible downstream links after output type recalculation', () => {
const graph = new LGraph()
const switchNode = createMatchTypeNode(graph)
const target = new LGraphNode('target')
target.addInput('image', 'IMAGE')
target.onConnectionsChange = vi.fn()
graph.add(target)
const source = createSourceNode(graph, 'IMAGE')
switchNode.connect(0, target, 0)
vi.mocked(target.onConnectionsChange).mockClear()
source.connect(0, switchNode, 0)
expect(switchNode.outputs[0].type).toBe('IMAGE')
expect(target.inputs[0].link).not.toBeNull()
expect(target.onConnectionsChange).toHaveBeenCalledWith(
LiteGraph.INPUT,
0,
true,
expect.anything(),
target.inputs[0]
)
})
test('disconnects incompatible downstream links after output type recalculation', () => {
const graph = new LGraph()
const switchNode = createMatchTypeNode(graph)
const target = new LGraphNode('target')
target.addInput('image', 'IMAGE')
graph.add(target)
const source = createSourceNode(graph, 'LATENT')
switchNode.connect(0, target, 0)
expect(target.inputs[0].link).not.toBeNull()
source.connect(0, switchNode, 0)
expect(switchNode.outputs[0].type).toBe('LATENT')
expect(target.inputs[0].link).toBeNull()
})
})

View File

@@ -1,46 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { GroupNodeWorkflowData } from './groupNode'
const appMock = vi.hoisted(() => ({
canvas: {
emitAfterChange: vi.fn(),
emitBeforeChange: vi.fn(),
selected_nodes: {}
},
registerExtension: vi.fn(),
registerNodeDef: vi.fn(),
rootGraph: {
convertToSubgraph: vi.fn(),
extra: {},
getNodeById: vi.fn(),
links: {},
nodes: [],
remove: vi.fn()
}
}))
const widgetStoreMock = vi.hoisted(() => ({
inputIsWidget: vi.fn((spec: unknown[]) =>
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
)
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => widgetStoreMock
app: {
registerExtension: vi.fn()
}
}))
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
@@ -58,46 +26,6 @@ function makeNode(type: string): ComfyNode {
}
}
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
return {
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
...overrides
} as ComfyNodeDef
}
function extension(): ComfyExtension {
const groupExtension = appMock.registerExtension.mock.calls.find(
([registered]) => registered.name === 'Comfy.GroupNode'
)?.[0]
if (!groupExtension) throw new Error('GroupNode extension was not registered')
return groupExtension as ComfyExtension
}
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
const groupExtension = extension()
if (!groupExtension.addCustomNodeDefs) {
throw new Error('GroupNode extension does not implement addCustomNodeDefs')
}
groupExtension.addCustomNodeDefs(defs, appMock as unknown as ComfyApp)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
appMock.registerNodeDef.mockReset()
widgetStoreMock.inputIsWidget.mockClear()
LiteGraph.registered_node_types = {}
addCustomNodeDefs({})
})
describe('replaceLegacySeparators', () => {
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
const nodes = [makeNode('workflow/My Group')]
@@ -176,390 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
const config = configFrom([], [[0, 1, 'IMAGE']])
expect(config.externalFrom[0][1]).toBe('IMAGE')
})
it('ignores external links without a type and accumulates multiple slots', () => {
const config = configFrom(
[],
[
[0, 1, null as unknown as string],
[0, 2, 'LATENT'],
[0, 3, 'IMAGE']
]
)
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
})
})
describe('GroupNodeConfig.getNodeDef', () => {
const imageNodeDef = makeNodeDef({
name: 'ImageNode',
input: {
required: {
image: ['IMAGE', {}],
mode: [['fast', 'slow'], {}]
},
optional: {
strength: ['FLOAT', { default: 1 }]
}
},
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
beforeEach(() => {
addCustomNodeDefs({ ImageNode: imageNodeDef })
})
it('returns registered definitions for normal node types', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
imageNodeDef
)
})
it('returns undefined for nodes without an index or a known type', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ type: 'UnknownNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
})
it('skips unlinked primitive nodes', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'PrimitiveNode' }],
links: [],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toBeUndefined()
})
it('derives primitive node type from the outgoing link type', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'PrimitiveNode' },
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toMatchObject({
input: { required: { value: ['IMAGE', {}] } },
output: ['IMAGE']
})
})
it('falls back to null when primitive combo target spec is not primitive', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{
index: 0,
type: 'PrimitiveNode',
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
external: []
})
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
input: { required: { value: [null, {}] } },
output: [null]
})
})
it('returns null for reroutes used only inside the group', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode' },
{ index: 1, type: 'Reroute' },
{ index: 2, type: 'ImageNode' }
],
links: [
[0, 0, 1, 0, 1, 'IMAGE'],
[1, 0, 2, 0, 2, 'IMAGE']
] as SerialisedLLinkArray[],
external: []
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
})
it('derives reroute type from outgoing target inputs', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'Reroute' },
{
index: 1,
type: 'ImageNode',
inputs: [{ name: 'image', type: 'IMAGE' }]
}
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: [[0, 0, 'IMAGE']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
output: ['IMAGE']
})
})
it('derives reroute type from incoming output metadata', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
{ index: 1, type: 'Reroute' }
],
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
external: [[1, 0, 'LATENT']]
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
output: ['LATENT']
})
})
it('derives pipe reroute type from external metadata when links omit it', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'Reroute' }],
links: [],
external: [[0, 0, 'MASK']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { MASK: ['MASK', { forceInput: true }] } },
output: ['MASK']
})
})
})
describe('GroupNodeConfig input and output mapping', () => {
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
const config = new GroupNodeConfig('group', {
nodes: [node],
links: [],
external: [],
config: {
0: {
input: {
hidden: { visible: false },
renamed: { name: 'Custom Name' }
},
output: {
1: { name: 'Custom Output' },
2: { visible: false }
}
}
}
})
config.nodeDef = makeNodeDef({
input: { required: {} },
output: [],
output_name: [],
output_is_list: []
})
return config
}
it('renames duplicate inputs and adds seed control metadata', () => {
const config = configWithNode({
index: 0,
type: 'Sampler',
title: 'Sampler A',
inputs: [{ name: 'seed', label: 'Seed Label' }]
})
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
const result = config.getInputConfig(
{ index: 0, type: 'Sampler', title: 'Sampler A' },
'seed',
seenInputs,
['INT', {}]
)
expect(result.name).toBe('Sampler A 1 seed')
expect(result.config).toEqual([
'INT',
{ control_after_generate: 'Sampler A control_after_generate' }
])
})
it('maps image upload widget aliases through converted widget names', () => {
const config = configWithNode({ index: 0, type: 'LoadImage' })
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
expect(
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
'IMAGEUPLOAD',
{ widget: 'customImage' }
])
).toMatchObject({
name: 'Custom Name',
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
})
})
it('splits widget inputs, socket inputs, and converted widget slots', () => {
const config = configWithNode({
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
})
const result = config.processWidgetInputs(
{
mode: ['COMBO', {}],
image: ['IMAGE', {}]
},
{
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
['mode', 'image'],
{}
)
expect(result.slots).toEqual(['image'])
expect(result.converted.get(0)).toBe('mode')
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
})
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
const config = configWithNode({
index: 0,
type: 'InputNode'
})
const inputMap: Record<number, number> = {}
config.processInputSlots(
{
image: ['IMAGE', {}],
hidden: ['LATENT', {}]
},
{ index: 0, type: 'InputNode' },
['image', 'hidden'],
{},
inputMap,
{}
)
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
expect(inputMap).toEqual({ 0: 0 })
})
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
const config = configWithNode({
index: 0,
type: 'OutputNode',
title: 'Output A',
outputs: [{ name: 'image', label: 'Rendered' }]
})
config.linksFrom[0] = {
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
}
config.processNodeOutputs(
{ index: 0, type: 'OutputNode', title: 'Output A' },
{ Rendered: 1 },
{
input: { required: {} },
output: ['IMAGE', 'LATENT', 'MASK'],
output_name: ['image', 'latent', 'mask'],
output_is_list: [false, true, false]
}
)
expect(config.outputVisibility).toEqual([false, true, false])
expect(config.nodeDef?.output).toEqual(['LATENT'])
expect(config.nodeDef?.output_is_list).toEqual([true])
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
})
})
describe('GroupNodeConfig.registerFromWorkflow', () => {
it('adds missing type actions and skips registration for incomplete groups', async () => {
const groupNodes: Record<string, GroupNodeWorkflowData> = {
Broken: {
nodes: [{ index: 0, type: 'MissingNode' }],
links: [],
external: []
}
}
const missingNodeTypes: Parameters<
typeof GroupNodeConfig.registerFromWorkflow
>[1] = []
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
expect(missingNodeTypes).toHaveLength(2)
expect(missingNodeTypes[0]).toMatchObject({
type: 'MissingNode',
hint: " (In group node 'workflow>Broken')"
})
const action = missingNodeTypes[1]
if (typeof action === 'string') {
throw new Error('Expected an action entry for the broken group node')
}
const target = document.createElement('button')
const { callback } = action.action as {
callback: (event: MouseEvent) => void
}
const event = new MouseEvent('click')
Object.defineProperty(event, 'target', { value: target })
callback(event)
expect(groupNodes.Broken).toBeUndefined()
expect(target.textContent).toBe('Removed')
expect(target.style.pointerEvents).toBe('none')
})
it('registers complete group node types and stores their generated node defs', async () => {
addCustomNodeDefs({
ImageNode: makeNodeDef({
name: 'ImageNode',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
})
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
await GroupNodeConfig.registerFromWorkflow(
{
Complete: {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: [[0, 0, 'IMAGE']]
}
},
[]
)
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
'workflow>Complete',
expect.objectContaining({
category: 'group nodes>workflow',
display_name: 'Complete',
name: 'workflow>Complete'
})
)
})
})

View File

@@ -1,89 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import {
NodeInputSlot,
NodeOutputSlot,
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import type {
DefaultConnectionColors,
ReadOnlyRect
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
type MockCanvasContext = CanvasRenderingContext2D & {
arc: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
clip: ReturnType<typeof vi.fn>
closePath: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
fillText: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
rect: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
save: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
}
function createContext(): MockCanvasContext {
return {
fillStyle: '#initial-fill',
strokeStyle: '#initial-stroke',
lineWidth: 7,
textAlign: 'start',
arc: vi.fn(),
beginPath: vi.fn(),
clip: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
lineTo: vi.fn(),
moveTo: vi.fn(),
rect: vi.fn(),
restore: vi.fn(),
save: vi.fn(),
stroke: vi.fn()
} as unknown as MockCanvasContext
}
function createColors(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn((type) => `connected-${type}`),
getDisconnectedColor: vi.fn((type) => `disconnected-${type}`)
}
}
function createNode(): LGraphNode {
return {
pos: [100, 200],
_collapsed_width: 80
} as LGraphNode
}
describe('NodeSlot', () => {
beforeEach(() => {
vi.stubGlobal(
'Path2D',
class {
arc = vi.fn()
}
)
})
describe('inputAsSerialisable', () => {
it('removes _data from serialized slot', () => {
const slot: INodeOutputSlot = {
@@ -145,328 +74,4 @@ describe('NodeSlot', () => {
expect(serialized.widget).not.toHaveProperty('options')
})
})
describe('rendering', () => {
it('draws an input label on the right and restores canvas styles', () => {
const ctx = createContext()
const slot = new NodeInputSlot(
{
name: 'input',
label: 'Input label',
type: 'FLOAT',
link: null,
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors(), highlight: true })
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 5, 0, Math.PI * 2)
expect(ctx.fillText).toHaveBeenCalledWith('Input label', 25, 20)
expect(ctx.fillStyle).toBe('#initial-fill')
expect(ctx.strokeStyle).toBe('#initial-stroke')
expect(ctx.lineWidth).toBe(7)
expect(ctx.textAlign).toBe('start')
})
it('draws output labels on the left and strokes output slots', () => {
const ctx = createContext()
const slot = new NodeOutputSlot(
{
name: 'output',
localized_name: 'Localized output',
type: 'FLOAT',
links: [toLinkId(1)],
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors() })
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith('Localized output', 5, 20)
expect(ctx.textAlign).toBe('start')
expect(ctx.strokeStyle).toBe('#initial-stroke')
})
it('draws event, box, arrow, grid, and low-quality slot shapes', () => {
const colorContext = createColors()
const node = createNode()
const eventCtx = createContext()
const boxCtx = createContext()
const arrowCtx = createContext()
const gridCtx = createContext()
const lowQualityCtx = createContext()
new NodeInputSlot(
{
name: 'event',
type: SlotType.Event,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(eventCtx, { colorContext })
new NodeInputSlot(
{
name: 'box',
type: 'FLOAT',
shape: RenderShape.BOX,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(boxCtx, { colorContext })
new NodeOutputSlot(
{
name: 'arrow',
type: 'FLOAT',
shape: RenderShape.ARROW,
links: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(arrowCtx, { colorContext })
new NodeInputSlot(
{
name: 'grid',
type: SlotType.Array,
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(gridCtx, { colorContext })
new NodeInputSlot(
{
name: 'low',
type: 'FLOAT',
link: null,
boundingRect: [110, 210, 10, 10]
},
node
).draw(lowQualityCtx, { colorContext, lowQuality: true })
expect(eventCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
expect(boxCtx.rect).toHaveBeenCalledWith(9.5, 10.5, 14, 10)
expect(arrowCtx.moveTo).toHaveBeenCalledWith(23, 15.5)
expect(gridCtx.rect).toHaveBeenCalledTimes(9)
expect(lowQualityCtx.rect).toHaveBeenCalledWith(11, 11, 8, 8)
expect(lowQualityCtx.fillText).not.toHaveBeenCalled()
})
it('draws hollow and multi-type slots', () => {
const colorContext = createColors()
const hollowCtx = createContext()
const multiCtx = createContext()
new NodeInputSlot(
{
name: 'hollow',
type: 'FLOAT',
shape: RenderShape.HollowCircle,
link: null,
boundingRect: [110, 210, 10, 10]
},
createNode()
).draw(hollowCtx, { colorContext, highlight: true })
new NodeInputSlot(
{
name: 'multi',
type: 'A,B,C,D,E',
link: toLinkId(1),
boundingRect: [110, 210, 10, 10]
},
createNode()
).draw(multiCtx, { colorContext })
expect(hollowCtx.clip).toHaveBeenCalledWith(expect.any(Object), 'evenodd')
expect(
vi
.mocked(colorContext.getConnectedColor)
.mock.calls.some(([type]) => type === 'A')
).toBe(true)
expect(multiCtx.fill.mock.calls.length).toBeGreaterThan(1)
expect(multiCtx.stroke).toHaveBeenCalled()
})
it('hides widget input labels and draws error rings', () => {
const ctx = createContext()
const slot = new NodeInputSlot(
{
name: 'widget-input',
label: 'Hidden label',
type: 'FLOAT',
link: null,
widget: { name: 'widget' },
hasErrors: true,
boundingRect: [110, 210, 10, 10]
},
createNode()
)
slot.draw(ctx, { colorContext: createColors() })
expect(ctx.fillText).not.toHaveBeenCalled()
expect(ctx.arc).toHaveBeenCalledWith(15, 15, 12, 0, Math.PI * 2)
expect(ctx.stroke).toHaveBeenCalled()
})
it('places directional labels above vertical slots', () => {
const rightCtx = createContext()
const leftCtx = createContext()
const node = createNode()
const input = new NodeInputSlot(
{
name: 'up',
type: 'FLOAT',
link: null,
dir: LinkDirection.UP,
boundingRect: [110, 210, 10, 10]
},
node
)
const output = new NodeOutputSlot(
{
name: 'down',
type: 'FLOAT',
links: null,
dir: LinkDirection.DOWN,
boundingRect: [110, 210, 10, 10]
},
node
)
input.draw(rightCtx, { colorContext: createColors() })
output.draw(leftCtx, { colorContext: createColors() })
expect(rightCtx.fillText).toHaveBeenCalledWith('up', 15, 5)
expect(leftCtx.fillText).toHaveBeenCalledWith('down', 15, 7)
})
})
describe('collapsed rendering', () => {
it('draws collapsed input and output arrows in their own directions', () => {
const inputCtx = createContext()
const outputCtx = createContext()
new NodeInputSlot(
{
name: 'input',
type: 'FLOAT',
shape: RenderShape.ARROW,
link: null,
boundingRect
},
createNode()
).drawCollapsed(inputCtx)
new NodeOutputSlot(
{
name: 'output',
type: 'FLOAT',
shape: RenderShape.ARROW,
links: null,
boundingRect
},
createNode()
).drawCollapsed(outputCtx)
expect(inputCtx.moveTo).toHaveBeenCalledWith(8, -15)
expect(inputCtx.lineTo).toHaveBeenCalledWith(-4, -19)
expect(outputCtx.moveTo).toHaveBeenCalledWith(86, -15)
expect(outputCtx.lineTo).toHaveBeenCalledWith(74, -19)
})
it('draws collapsed event and circle slots', () => {
const eventCtx = createContext()
const circleCtx = createContext()
new NodeInputSlot(
{
name: 'event',
type: SlotType.Event,
link: null,
boundingRect
},
createNode()
).drawCollapsed(eventCtx)
new NodeInputSlot(
{
name: 'circle',
type: 'FLOAT',
link: null,
boundingRect
},
createNode()
).drawCollapsed(circleCtx)
expect(eventCtx.rect).toHaveBeenCalledWith(-6.5, -19, 14, 8)
expect(circleCtx.arc).toHaveBeenCalledWith(0, -15, 4, 0, Math.PI * 2)
expect(circleCtx.fillStyle).toBe('#initial-fill')
})
})
describe('serialization and validation', () => {
it('serializes slot fields without the node reference', () => {
const slot = new NodeOutputSlot(
{
name: 'out',
type: 'FLOAT',
label: 'Output',
color_on: '#fff',
color_off: '#000',
shape: RenderShape.BOX,
dir: LinkDirection.RIGHT,
localized_name: 'Localized',
pos: [1, 2],
links: [toLinkId(3)],
slot_index: 4,
boundingRect: [1, 2, 3, 4]
},
createNode()
)
expect(slot.toJSON()).toEqual({
name: 'out',
type: 'FLOAT',
label: 'Output',
color_on: '#fff',
color_off: '#000',
shape: RenderShape.BOX,
dir: LinkDirection.RIGHT,
localized_name: 'Localized',
pos: [1, 2],
boundingRect: [1, 2, 3, 4],
links: [toLinkId(3)],
slot_index: 4
})
})
it('validates input and output targets by slot direction', () => {
const input = new NodeInputSlot(
{
name: 'input',
type: 'FLOAT',
link: null,
boundingRect
},
createNode()
)
const output = new NodeOutputSlot(
{
name: 'output',
type: 'FLOAT',
links: null,
boundingRect
},
createNode()
)
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
expect(input.isValidTarget(input)).toBe(false)
expect(output.isValidTarget(output)).toBe(false)
})
})
})

View File

@@ -6,7 +6,6 @@ import {
ExecutableNodeDTO,
LGraph,
LGraphEventMode,
LLink,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
@@ -25,14 +24,6 @@ beforeEach(() => {
})
describe('ExecutableNodeDTO Creation', () => {
it('should throw when the node has no graph', () => {
const node = new LGraphNode('Detached')
expect(() => new ExecutableNodeDTO(node, [], new Map(), undefined)).toThrow(
'Attempted to access LGraph reference that was null or undefined.'
)
})
it('should create DTO from regular node', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -216,74 +207,6 @@ describe('ExecutableNodeDTO Input Resolution', () => {
const resolved = dto.resolveInput(0)
expect(resolved).toBeUndefined()
})
it('should throw when resolving a repeated input path', () => {
const graph = new LGraph()
const node = new LGraphNode('Looped')
node.id = toNodeId(8)
node.title = 'Loop title'
node.addInput('in', 'IMAGE')
graph.add(node)
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
expect(() =>
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
).toThrow('Circular reference detected while resolving input 0')
})
it('should report repeated root inputs without title or path details', () => {
const graph = new LGraph()
const node = new LGraphNode('')
node.id = toNodeId(8)
node.title = ''
node.addInput('in', 'IMAGE')
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() =>
dto.resolveInput(0, new Set([`undefined:${node.id}[I]0`]))
).toThrow('Circular reference detected while resolving input 0 of node 8')
})
it('should throw when an input points at a missing link', () => {
const graph = new LGraph()
const node = new LGraphNode('Target')
node.addInput('in', 'IMAGE')
node.inputs[0].link = toLinkId(99)
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
})
it('should throw when an input link points at a missing source node', () => {
const graph = new LGraph()
const node = new LGraphNode('Target')
node.id = toNodeId(2)
node.addInput('in', 'IMAGE')
graph.add(node)
const link = new LLink(toLinkId(1), 'IMAGE', '404', 0, '2', 0)
graph.links.set(link.id, link)
node.inputs[0].link = link.id
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No input node found')
})
it('should throw when an input source has no DTO', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
graph.add(source)
const target = new LGraphNode('Target')
target.addInput('in', 'IMAGE')
graph.add(target)
source.connect(0, target, 0)
const dto = new ExecutableNodeDTO(target, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
})
})
describe('ExecutableNodeDTO Output Resolution', () => {
@@ -334,34 +257,6 @@ describe('ExecutableNodeDTO Output Resolution', () => {
expect(resolved?.node).toBe(dto)
expect(resolved?.origin_slot).toBe(0)
})
it('should throw when resolving a repeated output path', () => {
const graph = new LGraph()
const node = new LGraphNode('Looped')
node.id = toNodeId(9)
node.title = 'Loop title'
node.addOutput('out', 'IMAGE')
graph.add(node)
const dto = new ExecutableNodeDTO(node, ['parent'], new Map(), undefined)
expect(() =>
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
).toThrow('Circular reference detected while resolving output 0')
})
it('should report repeated root outputs without title or path details', () => {
const graph = new LGraph()
const node = new LGraphNode('')
node.id = toNodeId(9)
node.title = ''
node.addOutput('out', 'IMAGE')
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() =>
dto.resolveOutput(0, 'IMAGE', new Set([`undefined:${node.id}[O]0`]))
).toThrow('Circular reference detected while resolving output 0 of node 9')
})
})
describe('Muted node output resolution', () => {
@@ -473,135 +368,6 @@ describe('Bypass node output resolution', () => {
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(upstreamDto)
})
it('should use the first input when bypassing an any-type output', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('fallback', 'IMAGE')
bypassedNode.addOutput('first', 'IMAGE')
bypassedNode.addOutput('second', 'IMAGE')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 0)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(1, '*', new Set())
expect(resolved?.node).toBe(upstreamDto)
})
it('should use the same slot when bypassing an empty-type output', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('image', 'IMAGE')
bypassedNode.addOutput('out', 'IMAGE')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 0)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(0, '', new Set())
expect(resolved?.node).toBe(upstreamDto)
})
it('should use an exact matching input when bypassing different slot types', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('string', 'STRING')
bypassedNode.addInput('image', 'IMAGE')
bypassedNode.addOutput('latent', 'LATENT')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 1)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.node).toBe(upstreamDto)
})
it('should return undefined when no bypass input matches', () => {
const graph = new LGraph()
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('string', 'STRING')
bypassedNode.addOutput('out', 'LATENT')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
const dto = new ExecutableNodeDTO(bypassedNode, [], new Map(), undefined)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('No input types match'),
dto
)
})
})
describe('ALWAYS mode node output resolution', () => {
@@ -717,94 +483,6 @@ describe('Virtual node resolveVirtualOutput', () => {
expect(resolved).toBeUndefined()
expect(spy).toHaveBeenCalledWith(0)
})
it('should resolve through a virtual input link', () => {
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
sourceNode.addOutput('out', 'IMAGE')
graph.add(sourceNode)
const passthroughNode = new LGraphNode('Passthrough')
passthroughNode.addInput('in', 'IMAGE')
graph.add(passthroughNode)
sourceNode.connect(0, passthroughNode, 0)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
target_slot: 0,
resolve: () => ({ inputNode: passthroughNode })
} as unknown as LLink)
const nodeDtoMap = new Map()
const sourceDto = new ExecutableNodeDTO(
sourceNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(sourceDto.id, sourceDto)
const passthroughDto = new ExecutableNodeDTO(
passthroughNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(passthroughDto.id, passthroughDto)
const virtualDto = new ExecutableNodeDTO(
virtualNode,
[],
nodeDtoMap,
undefined
)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.node).toBe(sourceDto)
})
it('should throw when a virtual input link has no parent node', () => {
const graph = new LGraph()
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
target_slot: 0,
resolve: () => ({ inputNode: undefined })
} as unknown as LLink)
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'Virtual node failed to resolve parent'
)
})
it('should throw when a virtual input link parent has no DTO', () => {
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
graph.add(sourceNode)
const virtualNode = new LGraphNode('Virtual Get')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
virtualNode.resolveVirtualOutput = () => undefined
graph.add(virtualNode)
vi.spyOn(virtualNode, 'getInputLink').mockReturnValue({
target_slot: 0,
resolve: () => ({ inputNode: sourceNode })
} as unknown as LLink)
const dto = new ExecutableNodeDTO(virtualNode, [], new Map(), undefined)
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No input node DTO found'
)
})
})
describe('ExecutableNodeDTO Properties', () => {
@@ -910,23 +588,6 @@ describe('ExecutableNodeDTO Memory Efficiency', () => {
})
describe('ExecutableNodeDTO Integration', () => {
it('should delegate getInnerNodes for subgraph nodes', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const executableNodes = new Map()
const dto = new ExecutableNodeDTO(
subgraphNode,
[],
executableNodes,
undefined
)
const innerNodes = dto.getInnerNodes()
expect(innerNodes).toHaveLength(2)
expect(innerNodes[0]).toBeInstanceOf(ExecutableNodeDTO)
})
it('should work with SubgraphNode flattening', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -999,65 +660,6 @@ describe('ExecutableNodeDTO Integration', () => {
expect(Number(dto.node.id)).toBe(55) // Original node ID preserved
expect(Number(dto.subgraphNode?.id)).toBe(99) // Subgraph context
})
it('should throw when a subgraph output slot is missing', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No output found for flattened id'
)
})
it('should return undefined when a subgraph output has no inner link', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue(
undefined
)
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
})
it('should throw when a subgraph output link has no inner node', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
outputNode: undefined,
link: new LLink(toLinkId(1), 'IMAGE', '1', 0, '2', 0)
} as never)
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No output node found'
)
})
it('should throw when a subgraph output inner node has no DTO', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }],
nodeCount: 1
})
const subgraphNode = createTestSubgraphNode(subgraph)
const innerNode = subgraph.nodes[0]
vi.spyOn(subgraphNode, 'resolveSubgraphOutputLink').mockReturnValue({
outputNode: innerNode,
link: new LLink(toLinkId(1), 'IMAGE', String(innerNode.id), 0, '2', 0)
} as never)
const dto = new ExecutableNodeDTO(subgraphNode, [], new Map(), undefined)
expect(() => dto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No inner node DTO found'
)
})
})
describe('ExecutableNodeDTO Scale Testing', () => {

View File

@@ -1,277 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
import type { NodeId } from '@/types/nodeId'
type MenuConfig = {
title?: string
callback?: (item: { content: string; value: string }) => void
}
const { contextMenus, MockContextMenu } = vi.hoisted(() => {
const contextMenus: Array<{
options: unknown[]
config: MenuConfig
}> = []
class MockContextMenu {
constructor(options: unknown[], config: MenuConfig) {
contextMenus.push({ options, config })
}
}
return { contextMenus, MockContextMenu }
})
type TestSlot = SubgraphInput & {
arrange: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
draw: ReturnType<typeof vi.fn>
measure: ReturnType<typeof vi.fn>
onPointerMove: ReturnType<typeof vi.fn>
}
class TestIONode extends SubgraphIONodeBase<SubgraphInput> {
readonly id = 'subgraph-io' as NodeId
readonly emptySlot: SubgraphInput
readonly slots: SubgraphInput[]
readonly renameSlot = vi.fn()
readonly removeSlot = vi.fn()
constructor(
subgraph: Subgraph,
slots: SubgraphInput[],
emptySlot: SubgraphInput
) {
super(subgraph)
this.slots = slots
this.emptySlot = emptySlot
}
get allSlots(): SubgraphInput[] {
return [...this.slots, this.emptySlot]
}
get slotAnchorX(): number {
return this.pos[0] + this.size[0] - SubgraphIONodeBase.roundedRadius
}
onPointerDown(): void {}
openMenu(slot: SubgraphInput, event: CanvasPointerEvent): void {
this.showSlotContextMenu(slot, event)
}
renameByDoubleClick(slot: SubgraphInput, event: CanvasPointerEvent): void {
this.handleSlotDoubleClick(slot, event)
}
drawProtected(
ctx: CanvasRenderingContext2D,
colorContext: DefaultConnectionColors,
fromSlot?: SubgraphInput,
editorAlpha?: number
): void {
ctx.lineWidth = 99
ctx.strokeStyle = 'red'
ctx.fillStyle = 'blue'
ctx.font = '20px serif'
ctx.textBaseline = 'top'
this.drawSlots(ctx, colorContext, fromSlot, editorAlpha)
}
}
function createSlot(
name: string,
rect: [number, number, number, number],
links: number[] = []
): TestSlot {
const slot = {
name,
displayName: `${name} label`,
linkIds: links,
boundingRect: new Rectangle(...rect),
isPointerOver: false,
measure: vi.fn(() => [rect[2], rect[3]]),
arrange: vi.fn((nextRect: [number, number, number, number]) => {
slot.boundingRect.set(nextRect)
}),
onPointerMove: vi.fn((event: CanvasPointerEvent) => {
slot.isPointerOver = slot.boundingRect.containsXy(
event.canvasX,
event.canvasY
)
}),
disconnect: vi.fn(),
draw: vi.fn()
}
return slot as unknown as TestSlot
}
function createSubgraph() {
const prompt = vi.fn(
(_title: string, _value: string, callback: (value: string) => void) =>
callback('renamed')
)
return {
prompt,
subgraph: {
setDirtyCanvas: vi.fn(),
canvasAction: vi.fn(
(callback: (canvas: { prompt: typeof prompt }) => void) =>
callback({ prompt })
)
} as unknown as Subgraph
}
}
function createNode() {
const filled = createSlot('value', [20, 30, 80, 20], [1])
const empty = createSlot('', [20, 60, 80, 20])
const { subgraph, prompt } = createSubgraph()
const node = new TestIONode(subgraph, [filled], empty)
node.configure({
id: 'subgraph-io',
bounding: [10, 20, 100, 80],
pinned: false
})
return { node, filled, empty, subgraph, prompt }
}
function eventAt(x: number, y: number): CanvasPointerEvent {
return { canvasX: x, canvasY: y } as CanvasPointerEvent
}
beforeEach(() => {
contextMenus.length = 0
Object.assign(LiteGraph, { ContextMenu: MockContextMenu })
})
describe('SubgraphIONodeBase', () => {
it('moves, snaps, hit-tests, and serializes node bounds', () => {
const { node } = createNode()
node.move(5, -10)
expect(Array.from(node.pos)).toEqual([15, 10])
expect(node.containsPoint([20, 20])).toBe(true)
expect(node.asSerialisable()).toEqual({
id: 'subgraph-io',
bounding: [15, 10, 100, 80],
pinned: undefined
})
node.pinned = true
expect(node.snapToGrid(10)).toBe(false)
expect(node.asSerialisable().pinned).toBe(true)
})
it('tracks pointer entry, slot hover, and pointer leave', () => {
const { node, filled } = createNode()
const overResult = node.onPointerMove(eventAt(25, 35))
expect(overResult & CanvasItem.SubgraphIoNode).toBeTruthy()
expect(overResult & CanvasItem.SubgraphIoSlot).toBeTruthy()
expect(node.isPointerOver).toBe(true)
expect(filled.isPointerOver).toBe(true)
const outResult = node.onPointerMove(eventAt(500, 500))
expect(outResult).toBe(CanvasItem.Nothing)
expect(node.isPointerOver).toBe(false)
expect(filled.isPointerOver).toBe(false)
})
it('finds slots, arranges them, and restores drawing context state', () => {
const { node, filled } = createNode()
const ctx = {
lineWidth: 1,
strokeStyle: 'black',
fillStyle: 'white',
font: '12px sans-serif',
textBaseline: 'middle'
} as CanvasRenderingContext2D
node.arrange()
node.draw(ctx, {} as DefaultConnectionColors, filled)
expect(node.getSlotInPosition(100, 40)).toBe(filled)
expect(node.getSlotInPosition(500, 500)).toBeUndefined()
expect(filled.arrange).toHaveBeenCalled()
expect(node.size[0]).toBeGreaterThanOrEqual(108)
expect(ctx.lineWidth).toBe(1)
expect(ctx.strokeStyle).toBe('black')
expect(ctx.fillStyle).toBe('white')
expect(ctx.font).toBe('12px sans-serif')
expect(ctx.textBaseline).toBe('middle')
expect(filled.draw).toHaveBeenCalledWith(
expect.objectContaining({ ctx, fromSlot: filled })
)
})
it('prompts for non-empty slot rename on double click', () => {
const { node, filled, empty, prompt } = createNode()
node.renameByDoubleClick(empty, eventAt(0, 0))
expect(prompt).not.toHaveBeenCalled()
node.renameByDoubleClick(filled, eventAt(20, 30))
expect(prompt).toHaveBeenCalledWith(
'Slot name',
'value label',
expect.any(Function),
expect.any(Object)
)
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
})
it('opens slot context menu actions for connected non-empty slots', () => {
const { node, filled, subgraph } = createNode()
node.openMenu(filled, eventAt(20, 30))
expect(contextMenus).toHaveLength(1)
expect(contextMenus[0].config.title).toBe('value')
expect(contextMenus[0].options).toMatchObject([
{ value: 'disconnect' },
{ value: 'rename' },
null,
{ value: 'remove', className: 'danger' }
])
contextMenus[0].config.callback?.({
content: 'Disconnect Links',
value: 'disconnect'
})
contextMenus[0].config.callback?.({
content: 'Rename Slot',
value: 'rename'
})
contextMenus[0].config.callback?.({
content: 'Remove Slot',
value: 'remove'
})
expect(filled.disconnect).toHaveBeenCalled()
expect(node.renameSlot).toHaveBeenCalledWith(filled, 'renamed')
expect(node.removeSlot).toHaveBeenCalledWith(filled)
expect(subgraph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('does not open a context menu for the empty slot', () => {
const { node, empty } = createNode()
node.openMenu(empty, eventAt(20, 60))
expect(contextMenus).toHaveLength(0)
})
})

View File

@@ -1,273 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { LLink } from '@/lib/litegraph/src/LLink'
import type {
DefaultConnectionColors,
INodeInputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
function eventAt(x: number, y: number, button = 0): CanvasPointerEvent {
return { canvasX: x, canvasY: y, button } as CanvasPointerEvent
}
function createCanvasContext() {
return {
getTransform: vi.fn(() => new DOMMatrix()),
translate: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
setTransform: vi.fn(),
rect: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
strokeStyle: '',
lineWidth: 1,
font: '',
fillStyle: '',
textBaseline: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
}
describe('SubgraphInputNode', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('exposes input slots plus the empty slot and computes its anchor', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.inputNode.configure({
id: subgraph.inputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
expect(subgraph.inputNode.slots).toBe(subgraph.inputs)
expect(subgraph.inputNode.allSlots).toEqual([
subgraph.inputs[0],
subgraph.inputNode.emptySlot
])
expect(subgraph.inputNode.slotAnchorX).toBe(96)
})
it('sets link connector drag callbacks for left-clicked slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.inputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const pointer = {} as CanvasPointer
const linkConnector = {
dragNewFromSubgraphInput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
} as unknown as LinkConnector
subgraph.inputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
pointer.onDragStart?.(pointer)
pointer.onDragEnd?.(eventAt(40, 45))
pointer.finally?.()
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
subgraph,
subgraph.inputNode,
slot
)
expect(linkConnector.dropLinks).toHaveBeenCalledWith(
subgraph,
expect.objectContaining({ canvasX: 40 })
)
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('opens the slot context menu for right-clicked slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.inputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const menuSpy = vi.spyOn(
subgraph.inputNode as unknown as {
showSlotContextMenu(slot: unknown, event: unknown): void
},
'showSlotContextMenu'
)
subgraph.inputNode.onPointerDown(
eventAt(20, 25, 2),
{} as CanvasPointer,
{} as LinkConnector
)
subgraph.inputNode.onPointerDown(
eventAt(500, 500, 2),
{} as CanvasPointer,
{} as LinkConnector
)
expect(menuSpy).toHaveBeenCalledOnce()
expect(menuSpy).toHaveBeenCalledWith(
slot,
expect.objectContaining({ button: 2 })
)
})
it('renames and removes input slots through the parent subgraph', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.inputs[0]
const renameSpy = vi.spyOn(subgraph, 'renameInput')
const removeSpy = vi.spyOn(subgraph, 'removeInput')
subgraph.inputNode.renameSlot(slot, 'preview')
subgraph.inputNode.removeSlot(slot)
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
expect(removeSpy).toHaveBeenCalledWith(slot)
})
it('delegates connection checks and input-type connections', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.inputs[0]
const inputSlot = {
index: 0,
slot: { name: 'in', type: 'IMAGE' }
} as unknown as { index: number; slot: INodeInputSlot }
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(99)
vi.spyOn(targetNode, 'findInputByType').mockReturnValue(inputSlot)
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
const inputNode = fromPartial<NodeLike>({
canConnectTo: vi.fn(() => true)
})
expect(
subgraph.inputNode.canConnectTo(inputNode, inputSlot.slot, slot)
).toBe(true)
expect(
subgraph.inputNode.connectByType(0, targetNode, 'IMAGE', {
afterRerouteId: toRerouteId(7)
})
).toBe(link)
expect(connectSpy).toHaveBeenCalledWith(
inputSlot.slot,
targetNode,
toRerouteId(7)
)
vi.mocked(targetNode.findInputByType).mockReturnValue(undefined)
expect(
subgraph.inputNode.connectByType(0, targetNode, 'LATENT')
).toBeUndefined()
})
it('finds input slots by name and the first free slot by type', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'used', type: 'IMAGE' },
{ name: 'free', type: 'IMAGE' }
]
})
subgraph.inputs[0].linkIds.push(toLinkId(1))
expect(subgraph.inputNode.findOutputSlot('free')).toBe(subgraph.inputs[1])
expect(subgraph.inputNode.findOutputByType('IMAGE')).toBe(
subgraph.inputs[0]
)
expect(subgraph.inputNode.findOutputByType('LATENT')).toBeUndefined()
})
it('disconnects node inputs and clears floating links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(99)
const input = targetNode.addInput('image', 'IMAGE')
const floatingLink = new LLink(
toLinkId(9),
'IMAGE',
subgraph.inputNode.id,
0,
targetNode.id,
0
)
input._floatingLinks = new Set([floatingLink])
input.link = toLinkId(3)
const removeFloatingLinkSpy = vi.spyOn(subgraph, 'removeFloatingLink')
const setDirtyCanvasSpy = vi.spyOn(subgraph, 'setDirtyCanvas')
subgraph.inputNode._disconnectNodeInput(targetNode, input, undefined)
expect(removeFloatingLinkSpy).toHaveBeenCalledWith(floatingLink)
expect(input.link).toBeNull()
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(false, true)
})
it('draws the side rail and input slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.inputNode.configure({
id: subgraph.inputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
const ctx = createCanvasContext()
const drawSlotsSpy = vi.spyOn(
subgraph.inputNode as unknown as {
drawSlots(
ctx: unknown,
colorContext: unknown,
fromSlot: unknown,
editorAlpha: unknown
): void
},
'drawSlots'
)
subgraph.inputNode.drawProtected(
ctx,
{
getConnectedColor: vi.fn(() => '#fff'),
getDisconnectedColor: vi.fn(() => '#000')
} as unknown as DefaultConnectionColors,
subgraph.inputs[0],
0.5
)
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
expect(ctx.beginPath).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.setTransform).toHaveBeenCalled()
expect(drawSlotsSpy).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
getConnectedColor: expect.any(Function),
getDisconnectedColor: expect.any(Function)
}),
subgraph.inputs[0],
0.5
)
})
})

View File

@@ -1,225 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
function createWidget(
overrides: Partial<Pick<IBaseWidget, 'name' | 'type' | 'options'>> = {}
): IBaseWidget {
return {
name: overrides.name ?? 'strength',
type: overrides.type ?? 'FLOAT',
options: {
min: 0,
max: 1,
step: 0.1,
step2: 0.01,
precision: 2,
...overrides.options
}
} as IBaseWidget
}
describe('SubgraphInput', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('connects subgraph inputs to node inputs', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(10)
subgraph.add(targetNode)
const input = targetNode.addInput('image', 'IMAGE')
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const connectionSpy = vi.fn()
targetNode.onConnectionsChange = connectionSpy
const link = subgraph.inputs[0].connect(input, targetNode, toRerouteId(5))
expect(link).toBeInstanceOf(LLink)
expect(link?.origin_id).toBe(subgraph.inputNode.id)
expect(link?.target_id).toBe(targetNode.id)
expect(link?.parentId).toBe(toRerouteId(5))
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
expect(input.link).toBe(link?.id)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: true,
linkId: link?.id
})
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.INPUT,
0,
true,
link,
input
)
expect(afterChangeSpy).toHaveBeenCalled()
})
it('does not connect when the target node blocks the input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const targetNode = new LGraphNode('Target')
const input = targetNode.addInput('image', 'IMAGE')
targetNode.onConnectInput = vi.fn(() => false)
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
})
it('rejects widget inputs that do not match the promoted widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'strength', type: 'FLOAT' }]
})
const targetNode = new LGraphNode('Target')
const input = targetNode.addInput('strength', 'FLOAT')
const currentWidget = createWidget()
const otherWidget = createWidget({ options: { min: 1 } })
input.widget = { name: otherWidget.name }
targetNode.widgets = [otherWidget]
subgraph.inputs[0]._widget = currentWidget
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(subgraph.inputs[0].connect(input, targetNode)).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'Target input has invalid widget.',
input,
targetNode
)
})
it('tracks connected widgets and clears them on disconnect', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'strength', type: 'FLOAT' }]
})
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(10)
subgraph.add(targetNode)
const input = targetNode.addInput('strength', 'FLOAT')
const widget = createWidget()
input.widget = { name: widget.name }
targetNode.widgets = [widget]
const connectedSpy = vi.fn()
const disconnectedSpy = vi.fn()
subgraph.inputs[0].events.addEventListener('input-connected', connectedSpy)
subgraph.inputs[0].events.addEventListener(
'input-disconnected',
disconnectedSpy
)
const link = subgraph.inputs[0].connect(input, targetNode)
expect(subgraph.inputs[0]._widget).toBe(widget)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
expect(connectedSpy).toHaveBeenCalledOnce()
subgraph.inputs[0].disconnect()
expect(subgraph.inputs[0]._widget).toBeUndefined()
expect(subgraph.inputs[0].linkIds).toEqual([])
expect(disconnectedSpy).toHaveBeenCalledTimes(2)
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBeUndefined()
})
it('arranges and labels from the right edge', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const input = subgraph.inputs[0]
input.arrange([140, 30, 120, 40])
expect(Array.from(input.boundingRect)).toEqual([20, 30, 120, 40])
expect(input.pos).toEqual([120, 50])
expect(input.labelPos).toEqual([20, 50])
})
it('validates node inputs and subgraph outputs as targets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'source', type: 'IMAGE' }],
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const input = subgraph.inputs[0]
const imageInput = { name: 'image', type: 'IMAGE', link: null }
const latentInput = { name: 'latent', type: 'LATENT', link: null }
const imageOutput = fromPartial<INodeOutputSlot>({
name: 'image',
type: 'IMAGE',
links: []
})
expect(input.isValidTarget(imageInput as INodeInputSlot)).toBe(true)
expect(input.isValidTarget(latentInput as INodeInputSlot)).toBe(false)
expect(input.isValidTarget(imageOutput)).toBe(false)
expect(input.isValidTarget(subgraph.outputs[0])).toBe(true)
})
it('matches widget options by type and numeric constraints', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'strength', type: 'FLOAT' }]
})
const input = subgraph.inputs[0]
input._widget = createWidget()
expect(input.matchesWidget(createWidget())).toBe(true)
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(false)
expect(input.matchesWidget(createWidget({ options: { max: 2 } }))).toBe(
false
)
input._widget = undefined
expect(input.matchesWidget(createWidget({ type: 'INT' }))).toBe(true)
})
it('disconnects node inputs and removes link references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'image', type: 'IMAGE' }]
})
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(10)
subgraph.add(targetNode)
const input = targetNode.addInput('image', 'IMAGE')
const link = subgraph.inputs[0].connect(input, targetNode)
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const connectionSpy = vi.fn()
targetNode.onConnectionsChange = connectionSpy
subgraph.inputNode._disconnectNodeInput(targetNode, input, link)
expect(input.link).toBeNull()
expect(subgraph.inputs[0].linkIds).toEqual([])
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.INPUT,
0,
false,
link,
subgraph.inputs[0]
)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: link?.id
})
})
})

View File

@@ -17,13 +17,9 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -104,18 +100,6 @@ describe('SubgraphNode Construction', () => {
expect(subgraphNode.widgets).toEqual([])
})
it('warns when external code assigns widgets directly', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.widgets = []
expect(warn).toHaveBeenCalledWith(
'Cannot manually set widgets on SubgraphNode; use the promotion system.'
)
})
subgraphTest(
'should synchronize slots with subgraph definition',
({ subgraphWithNode }) => {
@@ -236,38 +220,6 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('throws when input rename events reference a missing slot', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
createTestSubgraphNode(subgraph)
expect(() =>
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 99,
oldName: 'input',
newName: 'missing'
})
).toThrow('Subgraph input not found')
})
it('throws when output rename events reference a missing slot', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'number' }]
})
createTestSubgraphNode(subgraph)
expect(() =>
subgraph.events.dispatch('renaming-output', {
output: subgraph.outputs[0],
index: 99,
oldName: 'output',
newName: 'missing'
})
).toThrow('Subgraph output not found')
})
it('represents promoted host widgets by input widgetId and WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -410,41 +362,6 @@ describe('SubgraphNode Synchronization', () => {
})
})
it('falls back projected widget fields when WidgetState is missing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widget = subgraphNode.widgets[0]
const id = promotedInput.widgetId
if (!id) throw new Error('Missing widgetId')
if (!widget) throw new Error('Missing projected widget')
useWidgetValueStore().deleteWidget(id)
expect(widget.name).toBe('text')
expect(widget.label).toBe('text')
expect(widget.y).toBe(0)
expect(widget.type).toBe('text')
expect(widget.options).toEqual({})
expect(widget.value).toBeUndefined()
expect(() => {
widget.label = 'Label'
widget.y = 12
widget.callback?.('updated')
}).not.toThrow()
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -526,111 +443,6 @@ describe('SubgraphNode Synchronization', () => {
'My Seed'
)
})
it('keeps rename behavior when widget state has been removed', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const widgetId = promotedInput.widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().deleteWidget(widgetId)
subgraph.renameInput(subgraph.inputs[0], 'Renamed Text')
expect(promotedInput.label).toBe('Renamed Text')
expect(useWidgetValueStore().getWidget(widgetId)).toBeUndefined()
})
it('rebinds promoted widgets when subgraph input objects are recreated', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
interiorNode.id = toNodeId(5)
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const originalSlot = subgraphNode.inputs[0]._subgraphSlot
const originalWidgetId = subgraphNode.inputs[0].widgetId
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized)
expect(subgraphNode.inputs).toHaveLength(1)
expect(subgraphNode.inputs[0]._subgraphSlot).toBe(subgraph.inputs[0])
expect(subgraphNode.inputs[0]._subgraphSlot).not.toBe(originalSlot)
expect(subgraphNode.inputs[0].widgetId).toBe(originalWidgetId)
expect(subgraphNode.widgets[0]).toMatchObject({
name: 'text',
value: 'initial'
})
})
it('stores DOM widget metadata from custom promoted host widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'dom', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
const interiorWidget = interiorNode.addWidget(
'text',
'value',
'initial',
() => {}
)
Object.assign(interiorWidget, { isDOMWidget: true })
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostWidget = fromPartial<IBaseWidget>({
name: 'host',
type: 'text',
value: 'host value',
options: {},
y: 0
})
class HostWidgetSubgraphNode extends SubgraphNode {
protected override createPromotedHostWidget() {
return hostWidget
}
}
const subgraphNode = new HostWidgetSubgraphNode(
subgraph.rootGraph,
subgraph,
fromPartial<ExportedSubgraphInstance>({
id: 10,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
properties: {}
})
)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
expect(subgraphNode.widgets).toEqual([hostWidget])
expect(useWidgetValueStore().getWidget(widgetId)).toMatchObject({
isDOMWidget: true
})
})
})
describe('SubgraphNode widget name collision on rename', () => {
@@ -846,31 +658,6 @@ describe('SubgraphNode Lifecycle', () => {
})
describe('SubgraphNode Basic Functionality', () => {
it('opens subgraphs from the title button and delegates other buttons', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const canvas = fromPartial<
Parameters<SubgraphNode['onTitleButtonClick']>[1]
>({
openSubgraph: vi.fn()
})
const fallback = vi
.spyOn(LGraphNode.prototype, 'onTitleButtonClick')
.mockImplementation(() => undefined)
subgraphNode.onTitleButtonClick(
fromPartial({ name: 'enter_subgraph' }),
canvas
)
subgraphNode.onTitleButtonClick(fromPartial({ name: 'other' }), canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(fallback).toHaveBeenCalledWith(
fromPartial({ name: 'other' }),
canvas
)
})
it('should inherit input types correctly', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -900,157 +687,6 @@ describe('SubgraphNode Basic Functionality', () => {
expect(subgraphNode.outputs[1].type).toBe('string')
expect(subgraphNode.outputs[2].type).toBe('*')
})
it('delegates title box drawing to a custom handler', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const onDrawTitleBox = vi.fn()
subgraphNode.onDrawTitleBox = onDrawTitleBox
const ctx = fromPartial<CanvasRenderingContext2D>({})
subgraphNode.drawTitleBox(ctx, {
scale: 2,
low_quality: false,
title_height: 30,
box_size: 12
})
expect(onDrawTitleBox).toHaveBeenCalledWith(
ctx,
30,
subgraphNode.renderingSize,
2
)
})
it('draws the default title box with and without the bitmap icon', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const ctx = fromPartial<CanvasRenderingContext2D>({
save: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
drawImage: vi.fn(),
restore: vi.fn()
})
subgraphNode.drawTitleBox(ctx, { scale: 1 })
subgraphNode.drawTitleBox(ctx, { scale: 1, low_quality: true })
expect(ctx.roundRect).toHaveBeenCalledWith(6, -24.5, 22, 20, 5)
expect(ctx.drawImage).toHaveBeenCalledTimes(1)
expect(ctx.restore).toHaveBeenCalledTimes(2)
})
it('returns undefined when a widgetId does not match a promoted input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
expect(
subgraphNode.getSlotFromWidget(
fromPartial<IBaseWidget>({
name: 'missing',
type: 'text',
value: '',
widgetId: 'missing-widget' as WidgetId
})
)
).toBeUndefined()
})
it('returns null for missing inner input links', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(subgraphNode.getInputLink(0)).toBeNull()
})
it('returns a translated input link for connected subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.id = toNodeId(9)
inner.addOutput('image', 'IMAGE')
subgraph.add(inner)
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
const link = subgraphNode.getInputLink(0)
expect(link?.origin_id).toBe(toNodeId(`${subgraphNode.id}:${inner.id}`))
expect(link?.origin_slot).toBe(0)
})
it('returns empty resolved input links when the subgraph input is isolated', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([])
})
it('returns resolved input links when the subgraph input is connected', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.id = toNodeId(9)
const input = inner.addInput('image', 'IMAGE')
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
expect(subgraphNode.resolveSubgraphInputLinks(0)).toEqual([
expect.objectContaining({
input,
inputNode: inner
})
])
})
it('returns resolved output links when the subgraph output is connected', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'output', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const inner = new LGraphNode('Inner')
inner.addOutput('image', 'IMAGE')
subgraph.add(inner)
subgraph.outputNode.slots[0].connect(inner.outputs[0], inner)
expect(subgraphNode.resolveSubgraphOutputLink(0)?.outputNode).toBe(inner)
})
it('returns a consistent slot shape only when all inner shapes match', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const slot = subgraph.inputs[0]
expect(subgraphNode.getSlotShape(slot, fromPartial({ shape: 4 }))).toBe(4)
const node = new LGraphNode('ShapeTarget')
const rounded = node.addInput('rounded', 'IMAGE')
const boxed = node.addInput('boxed', 'IMAGE')
rounded.shape = 4
boxed.shape = 3
subgraph.add(node)
slot.connect(rounded, node)
expect(subgraphNode.getSlotShape(slot, boxed)).toBeUndefined()
})
})
describe('SubgraphNode Execution', () => {
@@ -1140,27 +776,6 @@ describe('SubgraphNode Execution', () => {
expect(() => subgraph.add(subgraphNode)).toThrow()
})
it('throws a recursion error when traversal revisits the same subgraph node', () => {
const subgraph = createTestSubgraph({ name: '' })
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.title = 'Recursive Host'
expect(() =>
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
).toThrow('Circular reference detected')
})
it('describes unnamed recursive subgraph nodes', () => {
const subgraph = createTestSubgraph()
subgraph.name = ''
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.title = ''
expect(() =>
subgraphNode.getInnerNodes(new Map(), [], [], new Set([subgraphNode]))
).toThrow("node 1 of subgraph 'Unnamed Subgraph'")
})
it('should resolve cross-boundary links', () => {
// This test verifies that links can cross subgraph boundaries
// Currently this is a basic test - full cross-boundary linking
@@ -1186,171 +801,6 @@ describe('SubgraphNode Execution', () => {
})
})
describe('SubgraphNode preview exposure hydration', () => {
it('hydrates explicit preview exposure properties', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
subgraphNode.configure({
...subgraphNode.serialize(),
properties: {
previewExposures: [
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$preview'
}
]
}
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([
{
name: 'preview',
sourceNodeId: toNodeId(12),
sourcePreviewName: '$$preview'
}
])
})
it('clears exposures when an explicit empty property is serialized', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
store.addExposure(subgraphNode.rootGraph.id, String(subgraphNode.id), {
sourceNodeId: '12',
sourcePreviewName: '$$preview'
})
subgraphNode.configure({
...subgraphNode.serialize(),
properties: { previewExposures: [] }
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([])
})
it('hydrates legacy locator exposures when no explicit property exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
const legacyLocator = createNodeLocatorId(null, subgraphNode.id)
store.addExposure(subgraphNode.rootGraph.id, legacyLocator, {
sourceNodeId: '12',
sourcePreviewName: '$$legacy'
})
subgraphNode.configure({
...subgraphNode.serialize(),
properties: {}
} as ExportedSubgraphInstance)
expect(
store.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
).toEqual([
expect.objectContaining({
sourceNodeId: toNodeId(12),
sourcePreviewName: '$$legacy'
})
])
})
})
describe('SubgraphNode serialization', () => {
it('serializes promoted widget values and valid quarantine entries', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().setValue(widgetId, 42)
subgraphNode.properties.proxyWidgetErrorQuarantine = [
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 7
}
]
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toEqual([42])
expect(serialized.properties?.proxyWidgetErrorQuarantine).toEqual([
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 7
}
])
})
it('uses quarantined host values before serialized widget values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
subgraphNode.configure({
...subgraphNode.serialize(),
widgets_values: [11],
properties: {
proxyWidgetErrorQuarantine: [
{
originalEntry: ['-1', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1,
hostValue: 55
}
]
}
} as ExportedSubgraphInstance)
expect(useWidgetValueStore().getWidget(widgetId)?.value).toBe(55)
})
it('omits widget values when promoted widget state is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addWidget('number', 'value', 3, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const widgetId = subgraphNode.inputs[0].widgetId
if (!widgetId) throw new Error('Missing widgetId')
useWidgetValueStore().getWidget(widgetId)!.value = undefined
const serialized = subgraphNode.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
})
describe('SubgraphNode Edge Cases', () => {
it('should handle deep nesting', () => {
// Create a simpler deep nesting test that works with current implementation
@@ -1501,26 +951,6 @@ describe('SubgraphNode Cleanup', () => {
expect(abortSpy1).toHaveBeenCalledTimes(1)
expect(abortSpy2).toHaveBeenCalledTimes(1)
})
it('removes promoted widgets even when an input listener is absent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const onRemove = vi.fn()
subgraphNode.inputs[0]._widget = fromPartial<IBaseWidget>({
name: 'input',
type: 'number',
options: {},
y: 0,
onRemove
})
delete subgraphNode.inputs[0]._listenerController
subgraphNode.onRemoved()
expect(onRemove).toHaveBeenCalledOnce()
})
})
describe('SubgraphNode duplicate input pruning (#9977)', () => {
@@ -1646,49 +1076,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
expect(node.inputs).toHaveLength(2)
expect(node.inputs.map((i) => i.name)).toEqual(['x', 'y'])
})
it('rebinds duplicate serialized inputs by signature and then by name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'same', type: 'STRING' },
{ name: 'same', type: 'STRING' },
{ name: 'loose', type: 'INT' }
]
})
const node = new SubgraphNode(
subgraph.rootGraph,
subgraph,
fromPartial<ExportedSubgraphInstance>({
id: 1,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
inputs: [
{ name: 'same', type: 'STRING', link: null },
{ name: 'same', type: 'STRING', link: null },
{ name: 'loose', type: 'FLOAT', link: null },
{ name: 'missing', type: 'BOOLEAN', link: null }
],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
})
)
expect(node.inputs.map((input) => input.name)).toEqual([
'same',
'same',
'loose'
])
expect(node.inputs.map((input) => input._subgraphSlot)).toEqual([
subgraph.inputs[0],
subgraph.inputs[1],
subgraph.inputs[2]
])
})
})
describe('SubgraphNode label propagation', () => {

View File

@@ -1,245 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { LLink } from '@/lib/litegraph/src/LLink'
import type {
DefaultConnectionColors,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
function eventAt(x: number, y: number, button = 0): CanvasPointerEvent {
return { canvasX: x, canvasY: y, button } as CanvasPointerEvent
}
function createCanvasContext() {
return {
getTransform: vi.fn(() => new DOMMatrix()),
translate: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
setTransform: vi.fn(),
rect: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
strokeStyle: '',
lineWidth: 1,
font: '',
fillStyle: '',
textBaseline: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
}
describe('SubgraphOutputNode', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('exposes output slots plus the empty slot and computes its anchor', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.outputNode.configure({
id: subgraph.outputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
expect(subgraph.outputNode.slots).toBe(subgraph.outputs)
expect(subgraph.outputNode.allSlots).toEqual([
subgraph.outputs[0],
subgraph.outputNode.emptySlot
])
expect(subgraph.outputNode.slotAnchorX).toBe(24)
})
it('sets link connector drag callbacks for left-clicked slots', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const pointer = {} as CanvasPointer
const linkConnector = {
dragNewFromSubgraphOutput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
} as unknown as LinkConnector
subgraph.outputNode.onPointerDown(eventAt(20, 25), pointer, linkConnector)
pointer.onDragStart?.(pointer)
pointer.onDragEnd?.(eventAt(40, 45))
pointer.finally?.()
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
subgraph,
subgraph.outputNode,
slot
)
expect(linkConnector.dropLinks).toHaveBeenCalledWith(
subgraph,
expect.objectContaining({ canvasX: 40 })
)
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('opens the slot context menu for right-clicked slots', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
slot.boundingRect.updateTo([10, 20, 100, 30])
const menuSpy = vi.spyOn(
subgraph.outputNode as unknown as {
showSlotContextMenu(slot: unknown, event: unknown): void
},
'showSlotContextMenu'
)
subgraph.outputNode.onPointerDown(
eventAt(20, 25, 2),
{} as CanvasPointer,
{} as LinkConnector
)
subgraph.outputNode.onPointerDown(
eventAt(500, 500, 2),
{} as CanvasPointer,
{} as LinkConnector
)
expect(menuSpy).toHaveBeenCalledOnce()
expect(menuSpy).toHaveBeenCalledWith(
slot,
expect.objectContaining({ button: 2 })
)
})
it('renames and removes output slots through the parent subgraph', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
const renameSpy = vi.spyOn(subgraph, 'renameOutput')
const removeSpy = vi.spyOn(subgraph, 'removeOutput')
subgraph.outputNode.renameSlot(slot, 'preview')
subgraph.outputNode.removeSlot(slot)
expect(renameSpy).toHaveBeenCalledWith(slot, 'preview')
expect(removeSpy).toHaveBeenCalledWith(slot)
})
it('delegates connection checks and output-type connections', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
const slot = subgraph.outputs[0]
const outputSlot = {
index: 0,
slot: { name: 'out', type: 'IMAGE' }
} as unknown as { index: number; slot: INodeOutputSlot }
const targetNode = new LGraphNode('Target')
targetNode.id = toNodeId(99)
vi.spyOn(targetNode, 'findOutputByType').mockReturnValue(outputSlot)
const link = new LLink(toLinkId(1), 'IMAGE', toNodeId(1), 0, toNodeId(2), 0)
const connectSpy = vi.spyOn(slot, 'connect').mockReturnValue(link)
const outputNode = fromPartial<NodeLike>({
canConnectTo: vi.fn(() => true)
})
expect(
subgraph.outputNode.canConnectTo(outputNode, slot, outputSlot.slot)
).toBe(true)
expect(
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'IMAGE', {
afterRerouteId: toRerouteId(7)
})
).toBe(link)
expect(connectSpy).toHaveBeenCalledWith(
outputSlot.slot,
targetNode,
toRerouteId(7)
)
vi.mocked(targetNode.findOutputByType).mockReturnValue(undefined)
expect(
subgraph.outputNode.connectByTypeOutput(0, targetNode, 'LATENT')
).toBeUndefined()
})
it('finds the first free output slot of a matching type', () => {
const subgraph = createTestSubgraph({
outputs: [
{ name: 'used', type: 'IMAGE' },
{ name: 'free', type: 'IMAGE' }
]
})
subgraph.outputs[0].linkIds.push(toLinkId(1))
expect(subgraph.outputNode.findInputByType('IMAGE')).toBe(
subgraph.outputs[0]
)
expect(subgraph.outputNode.findInputByType('LATENT')).toBeUndefined()
})
it('draws the side rail and output slots', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'image', type: 'IMAGE' }]
})
subgraph.outputNode.configure({
id: subgraph.outputNode.id,
bounding: [10, 20, 100, 80],
pinned: false
})
const ctx = createCanvasContext()
const drawSlotsSpy = vi.spyOn(
subgraph.outputNode as unknown as {
drawSlots(
ctx: unknown,
colorContext: unknown,
fromSlot: unknown,
editorAlpha: unknown
): void
},
'drawSlots'
)
subgraph.outputNode.drawProtected(
ctx,
{
getConnectedColor: vi.fn(() => '#fff'),
getDisconnectedColor: vi.fn(() => '#000')
} as unknown as DefaultConnectionColors,
subgraph.outputs[0],
0.5
)
expect(ctx.translate).toHaveBeenCalledWith(10, 20)
expect(ctx.beginPath).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
expect(ctx.setTransform).toHaveBeenCalled()
expect(drawSlotsSpy).toHaveBeenCalledWith(
ctx,
expect.objectContaining({
getConnectedColor: expect.any(Function),
getDisconnectedColor: expect.any(Function)
}),
subgraph.outputs[0],
0.5
)
})
})

View File

@@ -1,168 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
describe('SubgraphOutput', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('connects node outputs to subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
sourceNode.id = toNodeId(10)
subgraph.add(sourceNode)
const output = sourceNode.addOutput('image', 'IMAGE')
const afterChangeSpy = vi.spyOn(subgraph, 'afterChange')
const connectionSpy = vi.fn()
sourceNode.onConnectionsChange = connectionSpy
const link = subgraph.outputs[0].connect(output, sourceNode, toRerouteId(5))
expect(link).toBeInstanceOf(LLink)
expect(link?.origin_id).toBe(sourceNode.id)
expect(link?.target_id).toBe(subgraph.outputNode.id)
expect(link?.parentId).toBe(toRerouteId(5))
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
expect(output.links).toEqual([link?.id])
expect(subgraph.getLink(link?.id ?? toLinkId(-1))).toBe(link)
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.OUTPUT,
0,
true,
link,
output
)
expect(afterChangeSpy).toHaveBeenCalled()
})
it('does not connect incompatible or blocked node outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
const latentOutput = sourceNode.addOutput('latent', 'LATENT')
expect(
subgraph.outputs[0].connect(latentOutput, sourceNode)
).toBeUndefined()
const imageOutput = sourceNode.addOutput('image', 'IMAGE')
sourceNode.onConnectOutput = vi.fn(() => false)
expect(subgraph.outputs[0].connect(imageOutput, sourceNode)).toBeUndefined()
})
it('throws when the output slot is not owned by the node', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
const foreignOutput = { name: 'image', type: 'IMAGE' } as INodeOutputSlot
expect(() =>
subgraph.outputs[0].connect(foreignOutput, sourceNode)
).toThrow('Slot is not an output of the given node')
})
it('disconnects existing links before accepting a replacement', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const firstNode = new LGraphNode('First')
firstNode.id = toNodeId(10)
subgraph.add(firstNode)
const firstOutput = firstNode.addOutput('image', 'IMAGE')
const firstLink = subgraph.outputs[0].connect(firstOutput, firstNode)
const secondNode = new LGraphNode('Second')
secondNode.id = toNodeId(11)
subgraph.add(secondNode)
const secondOutput = secondNode.addOutput('image', 'IMAGE')
const beforeChangeSpy = vi.spyOn(subgraph, 'beforeChange')
const secondLink = subgraph.outputs[0].connect(secondOutput, secondNode)
expect(beforeChangeSpy).toHaveBeenCalled()
expect(firstOutput.links).not.toContain(firstLink?.id)
expect(subgraph.outputs[0].linkIds).toEqual([secondLink?.id])
expect(secondOutput.links).toEqual([secondLink?.id])
})
it('arranges and labels from the left edge', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const output = subgraph.outputs[0]
output.arrange([20, 30, 120, 40])
expect(Array.from(output.boundingRect)).toEqual([20, 30, 120, 40])
expect(output.pos).toEqual([40, 50])
expect(output.labelPos).toEqual([60, 50])
})
it('validates output slots and subgraph inputs as targets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'source', type: 'IMAGE' }],
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const output = subgraph.outputs[0]
const imageOutput = fromPartial<INodeOutputSlot>({
name: 'image',
type: 'IMAGE',
links: []
})
const latentOutput = fromPartial<INodeOutputSlot>({
name: 'latent',
type: 'LATENT',
links: []
})
const imageInput = { name: 'image', type: 'IMAGE', link: null }
expect(output.isValidTarget(imageOutput)).toBe(true)
expect(output.isValidTarget(latentOutput)).toBe(false)
expect(output.isValidTarget(imageInput as INodeInputSlot)).toBe(false)
expect(output.isValidTarget(subgraph.inputs[0])).toBe(true)
})
it('disconnects links and notifies output nodes', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'preview', type: 'IMAGE' }]
})
const sourceNode = new LGraphNode('Source')
sourceNode.id = toNodeId(10)
subgraph.add(sourceNode)
const output = sourceNode.addOutput('image', 'IMAGE')
const link = subgraph.outputs[0].connect(output, sourceNode)
const removeLinkSpy = vi.spyOn(subgraph, 'removeLink')
const connectionSpy = vi.fn()
sourceNode.onConnectionsChange = connectionSpy
subgraph.outputs[0].disconnect()
expect(removeLinkSpy).toHaveBeenCalledWith(link?.id)
expect(output.links).not.toContain(link?.id)
expect(connectionSpy).toHaveBeenCalledWith(
NodeSlotType.OUTPUT,
0,
false,
link,
subgraph.outputs[0]
)
expect(subgraph.outputs[0].linkIds).toEqual([])
})
})

View File

@@ -2,18 +2,11 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { LGraphState } from '@/lib/litegraph/src/LGraph'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import type { ExportedSubgraph } from '../types/serialisation'
import type { ExportedSubgraph, ISerialisedNode } from '../types/serialisation'
import {
deduplicateSubgraphNodeIds,
topologicalSortSubgraphs
} from './subgraphDeduplication'
import { topologicalSortSubgraphs } from './subgraphDeduplication'
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
return {
@@ -39,196 +32,6 @@ function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
} as ExportedSubgraph
}
describe('deduplicateSubgraphNodeIds', () => {
it('remaps duplicate IDs in nodes, links, promoted widgets, and root proxy widgets', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = makeSubgraph('inner')
subgraph.nodes = [
{
id: 1,
type: 'Source',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
},
{
id: 2,
type: 'Target',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
subgraph.links = [
{
id: 1,
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: '*'
}
]
subgraph.widgets = [
{
id: 1,
name: 'text'
}
]
const rootNodes: ISerialisedNode[] = [
{
id: 10,
type: 'inner',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {
proxyWidgets: [[1, 'text'], 'not-an-entry']
}
},
{
id: 11,
type: 'Other',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {
proxyWidgets: [[1, 'text']]
}
}
]
const state: LGraphState = {
lastNodeId: 2,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
const result = deduplicateSubgraphNodeIds(
[subgraph],
new Set([1]),
state,
rootNodes
)
expect(result.subgraphs[0].nodes?.[0].id).toBe(3)
expect(result.subgraphs[0].links?.[0]).toMatchObject({
origin_id: 3,
target_id: 2
})
expect(result.subgraphs[0].widgets?.[0].id).toBe(3)
expect(result.rootNodes?.[0].properties?.proxyWidgets).toEqual([
['3', 'text'],
'not-an-entry'
])
expect(result.rootNodes?.[1].properties?.proxyWidgets).toEqual([
[1, 'text']
])
expect(subgraph.nodes?.[0].id).toBe(1)
expect(rootNodes[0].properties?.proxyWidgets).toEqual([
[1, 'text'],
'not-an-entry'
])
expect(state.lastNodeId).toBe(3)
expect(warn).toHaveBeenCalledWith(
'LiteGraph: duplicate subgraph node ID 1 remapped to 3'
)
warn.mockRestore()
})
it('tracks numeric IDs without root nodes and ignores non-numeric IDs', () => {
const subgraph = makeSubgraph('ids')
subgraph.nodes = [
{
id: '9',
type: 'NumericString',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
},
{
id: 'alpha',
type: 'NamedNode',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
const state: LGraphState = {
lastNodeId: 1,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
const result = deduplicateSubgraphNodeIds([subgraph], new Set(), state)
expect(result.rootNodes).toBeUndefined()
expect(result.subgraphs[0].nodes?.map((node) => node.id)).toEqual([
'9',
'alpha'
])
expect(state.lastNodeId).toBe(9)
})
it('throws when the numeric node ID space is exhausted', () => {
const subgraph = makeSubgraph('full')
subgraph.nodes = [
{
id: 1,
type: 'Duplicate',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
]
const state: LGraphState = {
lastNodeId: 100_000_000,
lastLinkId: toLinkId(0),
lastGroupId: 0,
lastRerouteId: toRerouteId(0)
}
expect(() =>
deduplicateSubgraphNodeIds([subgraph], new Set([1]), state)
).toThrow('Node ID space exhausted')
})
})
describe('topologicalSortSubgraphs', () => {
it('returns original order when there are no dependencies', () => {
const a = makeSubgraph('a')
@@ -274,11 +77,4 @@ describe('topologicalSortSubgraphs', () => {
it('returns original order for empty array', () => {
expect(topologicalSortSubgraphs([])).toEqual([])
})
it('returns original order when dependencies contain a cycle', () => {
const a = makeSubgraph('a', ['b'])
const b = makeSubgraph('b', ['a'])
expect(topologicalSortSubgraphs([a, b])).toEqual([a, b])
})
})

View File

@@ -1,57 +1,26 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import {
LGraph,
LGraphGroup,
findUsedSubgraphIds,
getDirectSubgraphIds,
LGraphNode,
LLink,
Reroute
getDirectSubgraphIds
} from '@/lib/litegraph/src/litegraph'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { UUID } from '@/lib/litegraph/src/litegraph'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
import {
getBoundaryLinks,
groupResolvedByOutput,
isNodeSlot,
isSubgraphInput,
isSubgraphOutput,
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
reorderSubgraphInputs,
splitPositionables
} from './subgraphUtils'
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.restoreAllMocks()
})
function makeNode(title: string): LGraphNode {
const node = new LGraphNode(title)
node.addInput('in', 'STRING')
node.addOutput('out', 'STRING')
return node
}
describe('getDirectSubgraphIds', () => {
it('should return empty set for graph with no subgraph nodes', () => {
const graph = new LGraph()
@@ -175,446 +144,5 @@ describe('subgraphUtils', () => {
expect(result.has(subgraph1.id)).toBe(true)
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
})
it('does not revisit subgraphs that were already discovered', () => {
const rootGraph = new LGraph()
const shared = createTestSubgraph({ name: 'Shared' })
const nestedParent = createTestSubgraph({ name: 'Nested parent' })
rootGraph.add(createTestSubgraphNode(shared))
rootGraph.add(createTestSubgraphNode(nestedParent))
nestedParent.add(createTestSubgraphNode(shared))
const result = findUsedSubgraphIds(
rootGraph,
new Map([
[shared.id, shared],
[nestedParent.id, nestedParent]
])
)
expect([...result]).toEqual([shared.id, nestedParent.id])
})
})
describe('splitPositionables', () => {
it('places each known positionable type into its own set', () => {
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
const node = new LGraphNode('Node')
const group = new LGraphGroup('Group')
const reroute = new Reroute(toRerouteId(1), new LGraph())
const unknown = fromPartial<Positionable>({ boundingRect: [0, 0, 1, 1] })
const result = splitPositionables([
node,
group,
reroute,
subgraph.inputNode,
subgraph.outputNode,
unknown
])
expect(result.nodes.has(node)).toBe(true)
expect(result.groups.has(group)).toBe(true)
expect(result.reroutes.has(reroute)).toBe(true)
expect(result.subgraphInputNodes.has(subgraph.inputNode)).toBe(true)
expect(result.subgraphOutputNodes.has(subgraph.outputNode)).toBe(true)
expect(result.unknown.has(unknown)).toBe(true)
})
})
describe('getBoundaryLinks', () => {
it('classifies selected node links by internal and boundary direction', () => {
const graph = new LGraph()
const source = makeNode('Source')
const selected = makeNode('Selected')
const selectedTarget = makeNode('Selected target')
const externalTarget = makeNode('External target')
graph.add(source)
graph.add(selected)
graph.add(selectedTarget)
graph.add(externalTarget)
const boundaryInput = source.connect(0, selected, 0)!
const internal = selected.connect(0, selectedTarget, 0)!
const boundaryOutput = selected.connect(0, externalTarget, 0)!
const result = getBoundaryLinks(
graph,
new Set([selected, selectedTarget])
)
expect(result.boundaryInputLinks).toEqual([boundaryInput])
expect(result.internalLinks).toEqual([internal])
expect(result.boundaryOutputLinks).toEqual([boundaryOutput])
expect(result.boundaryLinks).toEqual([])
expect(result.boundaryFloatingLinks).toEqual([])
})
it('ignores unresolved input links and warns with the missing id', () => {
const graph = new LGraph()
const node = makeNode('Node')
graph.add(node)
node.inputs[0].link = toLinkId(404)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = getBoundaryLinks(graph, new Set([node]))
expect(result.internalLinks).toEqual([])
expect(warn).toHaveBeenCalledWith('Failed to resolve link ID [404]')
})
it('treats reroutes with outside participants as boundary links', () => {
const graph = new LGraph()
const source = makeNode('Source')
const target = makeNode('Target')
graph.add(source)
graph.add(target)
const link = source.connect(0, target, 0)!
const reroute = new Reroute(toRerouteId(1), graph, [10, 10], undefined, [
link.id
])
link.parentId = reroute.id
graph.reroutes.set(reroute.id, reroute)
const result = getBoundaryLinks(graph, new Set([reroute]))
expect(result.boundaryLinks).toEqual([link])
})
it('handles unlinked nodes, groups, subgraph-input links, and floating links', () => {
const graph = new LGraph()
const selected = makeNode('Selected')
const group = new LGraphGroup('Group')
graph.add(selected)
const subgraphInputLink = new LLink(
toLinkId(80),
'STRING',
SUBGRAPH_INPUT_ID,
0,
selected.id,
0
)
graph.links.set(subgraphInputLink.id, subgraphInputLink)
selected.inputs[0].link = subgraphInputLink.id
const floatingLink = new LLink(toLinkId(81), 'STRING', 1, 0, 2, 0)
const outsideReroute = new Reroute(toRerouteId(8), graph, [0, 0])
floatingLink.parentId = outsideReroute.id
graph.reroutes.set(outsideReroute.id, outsideReroute)
selected.outputs[0]._floatingLinks = new Set([floatingLink])
const result = getBoundaryLinks(graph, new Set([selected, group]))
expect(result.boundaryInputLinks).toEqual([subgraphInputLink])
expect(result.boundaryFloatingLinks).toEqual([floatingLink])
expect(result.boundaryOutputLinks).toEqual([])
})
})
describe('multiClone', () => {
it('falls back to cloned serialized data when a node type cannot be created', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = new LGraphNode('Fallback')
node.type = 'missing/type'
node.properties = { nested: { value: 1 } }
const result = multiClone([node])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ type: 'missing/type' })
expect(result[0].properties).toEqual({ nested: { value: 1 } })
expect(result[0].properties).not.toBe(node.serialize().properties)
expect(warn).toHaveBeenCalledWith('Failed to create node', 'missing/type')
})
})
describe('groupResolvedByOutput', () => {
it('groups connections by subgraph input before regular output', () => {
const subgraph = createTestSubgraph({ inputCount: 1 })
const output = { name: 'out' }
const first = {
subgraphInput: subgraph.inputs[0],
output,
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
} as ResolvedConnection
const second = {
subgraphInput: subgraph.inputs[0],
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
} as ResolvedConnection
const result = groupResolvedByOutput([first, second])
expect(result.get(subgraph.inputs[0])).toEqual([first, second])
expect(result.has(output)).toBe(false)
})
it('keeps unresolved output connections in separate groups', () => {
const first = {
link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0)
} as ResolvedConnection
const second = {
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0)
} as ResolvedConnection
const result = groupResolvedByOutput([first, second])
expect(result.size).toBe(2)
expect([...result.values()]).toEqual([[first], [second]])
})
})
describe('mapSubgraphInputsAndLinks', () => {
it('creates unique input metadata and rewrites link origins', () => {
const targetInput = makeNode('Target').inputs[0]
targetInput.localized_name = 'Prompt'
targetInput.label = 'Prompt label'
const link = new LLink(toLinkId(1), 'STRING', 10, 0, 20, 0)
const connection = {
link,
input: targetInput
} as ResolvedConnection
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks([connection], links, new Map())
expect(inputs).toHaveLength(1)
expect(inputs[0]).toMatchObject({
name: 'in',
localized_name: 'Prompt',
label: 'Prompt label',
type: 'STRING',
linkIds: [toLinkId(1)]
})
expect(links[0]).toMatchObject({
origin_id: '-10',
origin_slot: 0,
target_id: 20,
target_slot: 0
})
})
it('restores the original link parent while mapping reroutes', () => {
const targetInput = makeNode('Target').inputs[0]
const link = new LLink(
toLinkId(1),
'STRING',
10,
0,
20,
0,
toRerouteId(2)
)
const first = new Reroute(
toRerouteId(1),
new LGraph(),
undefined,
toRerouteId(99)
)
const second = new Reroute(
toRerouteId(2),
new LGraph(),
undefined,
first.id
)
const links: SerialisableLLink[] = []
mapSubgraphInputsAndLinks(
[{ link, input: targetInput } as ResolvedConnection],
links,
new Map([
[first.id, first],
[second.id, second]
])
)
expect(link.parentId).toBe(toRerouteId(99))
expect(links[0].parentId).toBe(second.id)
expect(first.parentId).toBeUndefined()
expect(second.parentId).toBe(first.id)
})
it('skips unresolved input connections and uniquifies duplicate names', () => {
const firstInput = makeNode('First').inputs[0]
firstInput.localized_name = 'Prompt'
const secondInput = makeNode('Second').inputs[0]
secondInput.localized_name = 'Prompt'
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks(
[
{ link: new LLink(toLinkId(1), 'STRING', 1, 0, 2, 0) },
{
link: new LLink(toLinkId(2), 'STRING', 1, 0, 3, 0),
input: firstInput
},
{
link: new LLink(toLinkId(3), 'STRING', 1, 0, 4, 0),
input: secondInput
}
] as ResolvedConnection[],
links,
new Map()
)
expect(inputs.map((input) => input.name)).toEqual(['in', 'in_1'])
expect(inputs.map((input) => input.localized_name)).toEqual([
'Prompt',
'Prompt_1'
])
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
})
})
describe('mapSubgraphOutputsAndLinks', () => {
it('creates unique output metadata and rewrites link targets', () => {
const output = makeNode('Source').outputs[0]
output.type = 'IMAGE'
output.localized_name = 'Image'
output.label = 'Image label'
const link = new LLink(toLinkId(1), 'IMAGE', 10, 0, 20, 0)
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks(
[{ link, output } as ResolvedConnection],
links,
new Map()
)
expect(outputs).toHaveLength(1)
expect(outputs[0]).toMatchObject({
name: 'out',
localized_name: 'Image',
label: 'Image label',
type: 'IMAGE',
linkIds: [toLinkId(1)]
})
expect(links[0]).toMatchObject({
origin_id: 10,
origin_slot: 0,
target_id: '-20',
target_slot: 0
})
})
it('skips unresolved output connections and uniquifies duplicate names', () => {
const firstOutput = makeNode('First').outputs[0]
firstOutput.localized_name = 'Image'
const secondOutput = makeNode('Second').outputs[0]
secondOutput.localized_name = 'Image'
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks(
[
{ link: new LLink(toLinkId(1), 'IMAGE', 1, 0, 2, 0) },
{
link: new LLink(toLinkId(2), 'IMAGE', 1, 0, 3, 0),
output: firstOutput
},
{
link: new LLink(toLinkId(3), 'IMAGE', 1, 0, 4, 0),
output: secondOutput
}
] as ResolvedConnection[],
links,
new Map()
)
expect(outputs.map((output) => output.name)).toEqual(['out', 'out_1'])
expect(outputs.map((output) => output.localized_name)).toEqual([
'Image',
'Image_1'
])
expect(links.map((link) => link.id)).toEqual([toLinkId(2), toLinkId(3)])
})
})
describe('reorderSubgraphInputs', () => {
it('returns when the host has no subgraph', () => {
expect(() =>
reorderSubgraphInputs(
{ subgraph: null } as unknown as Parameters<
typeof reorderSubgraphInputs
>[0],
[]
)
).not.toThrow()
})
it('logs and leaves inputs unchanged for invalid permutations', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
reorderSubgraphInputs(host, [1, 1])
expect(subgraph.inputs.map((input) => input.name)).toEqual([
'first',
'second'
])
expect(error).toHaveBeenCalledWith(
'reorderSubgraphInputs: orderedIndices must be a permutation of 0..1',
[1, 1]
)
})
it('dispatches reorder details when the input order changes', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
reorderSubgraphInputs(host, [1, 0])
expect(dispatch).toHaveBeenCalledWith('inputs-reordered', {
subgraph,
oldOrder: expect.any(Array),
newOrder: expect.any(Array)
})
expect(subgraph.inputs.map((input) => input.name)).toEqual([
'second',
'first'
])
})
it('does not dispatch when the input order is unchanged', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
subgraph.inputs[0].linkIds.push(toLinkId(404))
host.inputs[0].link = toLinkId(405)
const dispatch = vi.spyOn(subgraph.events, 'dispatch')
reorderSubgraphInputs(host, [0, 1])
expect(dispatch).not.toHaveBeenCalled()
})
})
describe('slot type guards', () => {
it('identifies subgraph slots and node slots', () => {
const subgraph = createTestSubgraph({ inputCount: 1, outputCount: 1 })
const node = makeNode('Node')
expect(isSubgraphInput(subgraph.inputs[0])).toBe(true)
expect(isSubgraphInput(subgraph.outputs[0])).toBe(false)
expect(isSubgraphOutput(subgraph.outputs[0])).toBe(true)
expect(isSubgraphOutput(node.outputs[0])).toBe(false)
expect(isNodeSlot(node.inputs[0])).toBe(true)
expect(isNodeSlot(node.outputs[0])).toBe(true)
expect(isNodeSlot(null)).toBe(false)
expect(isNodeSlot({})).toBe(false)
})
})
})

View File

@@ -1,116 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '../LGraphNode'
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
type ArrangeNode = LGraphNode & { title: string }
function nodeFixture(
title: string,
pos: [number, number],
size: [number, number]
): ArrangeNode {
const graphNode = {
title,
pos,
size,
setPos: vi.fn((x: number, y: number) => {
graphNode.pos = [x, y]
})
}
return graphNode as unknown as ArrangeNode
}
describe('arrange utilities', () => {
it('returns null when no boundary node is available', () => {
expect(getBoundaryNodes([])).toBeNull()
expect(getBoundaryNodes(undefined as unknown as LGraphNode[])).toBeNull()
})
it('finds the furthest node in each direction', () => {
const top = nodeFixture('top', [10, -10], [20, 20])
const right = nodeFixture('right', [100, 0], [50, 20])
const bottom = nodeFixture('bottom', [0, 80], [20, 60])
const left = nodeFixture('left', [-20, 0], [10, 10])
expect(getBoundaryNodes([top, right, bottom, left])).toEqual({
top,
right,
bottom,
left
})
})
it('does not distribute zero or one node', () => {
expect(distributeNodes([])).toEqual([])
expect(distributeNodes([nodeFixture('single', [0, 0], [10, 10])])).toEqual(
[]
)
})
it('distributes nodes horizontally by sorted position', () => {
const first = nodeFixture('first', [0, 10], [10, 10])
const middle = nodeFixture('middle', [30, 20], [10, 10])
const last = nodeFixture('last', [60, 30], [20, 10])
const result = distributeNodes([last, first, middle], true)
expect(result.map(({ node: resultNode }) => resultNode.title)).toEqual([
'first',
'middle',
'last'
])
expect(first.pos).toEqual([0, 10])
expect(middle.pos).toEqual([30, 20])
expect(last.pos).toEqual([60, 30])
})
it('distributes nodes vertically by sorted position', () => {
const first = nodeFixture('first', [10, 0], [10, 10])
const middle = nodeFixture('middle', [20, 30], [10, 10])
const last = nodeFixture('last', [30, 60], [10, 20])
distributeNodes([last, first, middle])
expect(first.pos).toEqual([10, 0])
expect(middle.pos).toEqual([20, 30])
expect(last.pos).toEqual([30, 60])
})
it('aligns nodes to each boundary edge', () => {
const nodesForAlign = () => [
nodeFixture('top', [10, 0], [10, 10]),
nodeFixture('right', [40, 10], [30, 10]),
nodeFixture('bottom', [20, 50], [10, 30]),
nodeFixture('left', [-10, 20], [10, 10])
]
expect(
alignNodes(nodesForAlign(), 'left').map(({ newPos }) => newPos.x)
).toEqual([-10, -10, -10, -10])
expect(
alignNodes(nodesForAlign(), 'right').map(({ newPos }) => newPos.x)
).toEqual([60, 40, 60, 60])
expect(
alignNodes(nodesForAlign(), 'top').map(({ newPos }) => newPos.y)
).toEqual([0, 0, 0, 0])
expect(
alignNodes(nodesForAlign(), 'bottom').map(({ newPos }) => newPos.y)
).toEqual([70, 70, 50, 70])
})
it('aligns to an explicit node when provided', () => {
const anchor = nodeFixture('anchor', [100, 200], [50, 60])
const target = nodeFixture('target', [0, 0], [10, 20])
const result = alignNodes([target], 'bottom', anchor)
expect(result[0].newPos).toEqual({ x: 0, y: 240 })
expect(target.setPos).toHaveBeenCalledWith(0, 240)
})
it('returns no positions when alignment has no usable nodes', () => {
expect(alignNodes([], 'left')).toEqual([])
expect(alignNodes(undefined as unknown as LGraphNode[], 'left')).toEqual([])
})
})

View File

@@ -1,119 +0,0 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
findFirstNode,
findFreeSlotOfType,
getAllNestedItems
} from './collections'
import type { Positionable } from '../interfaces'
const graphNodeMock = vi.hoisted(() => ({
LGraphNode: class TestLGraphNode {
constructor(readonly title: string) {}
}
}))
vi.mock('@/lib/litegraph/src/LGraphNode', () => graphNodeMock)
describe('getAllNestedItems', () => {
it('returns empty for an undefined input set', () => {
expect(
getAllNestedItems(undefined as unknown as ReadonlySet<Positionable>)
).toEqual(new Set())
})
it('flattens nested children while skipping pinned and repeated items', () => {
const leaf = fromPartial<Positionable>({ pinned: false })
const hiddenChild = fromPartial<Positionable>({ pinned: false })
const pinned = fromPartial<Positionable>({
pinned: true,
children: new Set([leaf, hiddenChild])
})
const parent = fromPartial<Positionable>({
pinned: false,
children: new Set([leaf, pinned])
})
const result = getAllNestedItems(new Set([parent, leaf]))
expect(result).toEqual(new Set([parent, leaf]))
expect(result.has(hiddenChild)).toBe(false)
})
})
describe('findFirstNode', () => {
it('returns the first graph node from a mixed collection', () => {
const node = new LGraphNode('node')
expect(findFirstNode([{ pinned: false } as Positionable, node])).toBe(node)
})
it('returns undefined when no graph node is present', () => {
expect(findFirstNode([{ pinned: false } as Positionable])).toBeUndefined()
})
})
describe('findFreeSlotOfType', () => {
interface Slot {
type: string
links: number[]
}
const hasNoLinks = (slot: Slot) => slot.links.length === 0
it('returns undefined for an empty slot list', () => {
expect(findFreeSlotOfType([], 'IMAGE', hasNoLinks)).toBeUndefined()
})
it('prefers the first free exact type match', () => {
const slots = [
{ type: 'IMAGE', links: [1] },
{ type: 'IMAGE', links: [] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to a free wildcard before an occupied exact slot', () => {
const slots = [
{ type: 'IMAGE', links: [1] },
{ type: '*', links: [] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied exact slot before an occupied wildcard', () => {
const slots = [
{ type: '*', links: [1] },
{ type: 'IMAGE', links: [2] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied wildcard when no exact slot matches', () => {
const slots = [
{ type: 'LATENT', links: [1] },
{ type: '*', links: [2] }
]
expect(findFreeSlotOfType(slots, 'IMAGE', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
})

View File

@@ -3690,13 +3690,13 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"runCount": "Generations",
"generating": "Generating…",
"stopGeneration": "Stop generation",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
@@ -3705,26 +3705,41 @@
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"creditApproximateInfo": "Credit consumption shown here is approximate due to 3rd party provider calculations, exact values are available in your job history once the workflow is executed",
"creditBreakdown": "Credit breakdown by model",
"usesCredits": "Uses credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"startTour": "Take a tour of App Mode",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",
"oneOutput": "1 output"
"getStarted": "Click {runButton} to get started."
},
"getStarted": {
"title": "Get started with Apps",
"subtitle": "Pick an app template to get started. Each one is built on a workflow.",
"templates": "Templates",
"importWorkflow": "Import workflow",
"discoverAll": "Discover all templates"
},
"buildPrompt": {
"title": "Make this workflow an App",
"description": "Pick which nodes become inputs and outputs, and we'll generate a simple form anyone can run.",
"button": "Build your App"
},
"appModeToolbar": {
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
"appsEmptyMessageAction": "Click below to build your first app.",
"buildAnApp": "Build an app",
"create": "Create",
"createApp": "Create app"
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3767,6 +3782,7 @@
"support": "contact our support",
"promptShow": "Show error report"
},
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
@@ -4489,5 +4505,39 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"onboardingCoachmarks": {
"stepLabel": "Step {current} of {total}",
"skip": "Skip",
"next": "Next",
"done": "Done",
"appMode": {
"landing": {
"title": "Welcome to Apps",
"body": "A quick tour of the essentials, in about a minute. You'll fill inputs and see your first generation.",
"primary": "Start tutorial",
"skip": "Skip for now"
},
"inputs": {
"title": "Add your inputs",
"body": "Add what you want to work with. Your inputs are what the app turns into results."
},
"run": {
"title": "Run your app",
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
},
"outputs": {
"title": "Get your results",
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
},
"assetsButton": {
"title": "Open your assets",
"body": "Click the Assets button in the left toolbar to open your media library."
},
"assets": {
"title": "Find all your assets",
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
}
}
}
}

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -215,7 +216,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -19,7 +19,7 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 self-center text-credit')" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
@@ -81,7 +81,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
@@ -133,7 +133,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
{{ displayPrepaid }}
</span>
</div>
@@ -179,7 +179,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import { CREDITS_ICON, formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -264,6 +264,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -4,14 +4,20 @@
value: buttonTooltip,
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
:class="
cn(
'subscribe-to-run-button gap-1.5 rounded-lg px-4 whitespace-nowrap [--credits-pill-base:var(--color-brand-yellow)]',
large ? 'h-10 text-sm' : 'h-8'
)
"
variant="brand-yellow"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
>
<i class="pi pi-lock" />
{{ buttonLabel }}
<slot name="trailing" />
</Button>
</template>
@@ -25,8 +31,12 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
const { large = false } = defineProps<{ large?: boolean }>()
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
@@ -43,11 +53,13 @@ const buttonLabel = computed(() => {
: t('subscription.subscribeToRun')
})
const buttonTooltip = computed(() =>
canResubscribe.value
const buttonTooltip = computed(() => {
const tooltip = canResubscribe.value
? t('subscription.subscribeToRunFull')
: t('subscription.inactive.memberRunTooltip')
)
// Skip the tooltip when it would only repeat the visible button label
return tooltip === buttonLabel.value ? undefined : tooltip
})
function handleSubscribeToRun() {
if (isCloud) {

View File

@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import CoachmarkCard from './CoachmarkCard.vue'
afterEach(cleanup)
describe('CoachmarkCard', () => {
it('renders the title, message and subtitle', () => {
render(CoachmarkCard, {
props: {
title: 'This is your canvas',
message: 'Scroll to zoom.',
subtitle: 'Step 1 of 3'
}
})
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
})
it('applies titleId to the heading for aria-labelledby wiring', () => {
render(CoachmarkCard, {
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
})
expect(screen.getByRole('heading').id).toBe('title-1')
})
it('applies messageId to the message for aria-describedby wiring', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
})
expect(screen.getByText('Body copy').id).toBe('desc-1')
})
it('omits the subtitle when not provided', () => {
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
expect(screen.queryByText('Step 1 of 3')).toBeNull()
})
it('renders the image when an image src is given', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M', image: '/foo.png' }
})
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
})
it('renders an image slot in place of the default image', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
})
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
'src',
'/slot.png'
)
})
it('renders the actions slot', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { actions: () => h('button', 'Next') }
})
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
})

View File

@@ -0,0 +1,50 @@
<template>
<div
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
>
<div
v-if="image || $slots.image"
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
>
<slot name="image">
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
</slot>
</div>
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
<div class="flex flex-col items-start gap-2 self-stretch">
<p
v-if="subtitle"
:id="subtitleId"
class="m-0 text-xs/normal text-base-foreground"
>
{{ subtitle }}
</p>
<h3
:id="titleId"
class="m-0 text-base/normal font-semibold text-base-foreground"
>
{{ title }}
</h3>
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
{{ message }}
</p>
</div>
<div v-if="$slots.actions" class="flex items-center gap-3">
<slot name="actions" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
defineProps<{
title: string
titleId?: string
message: string
subtitle?: string
subtitleId?: string
image?: string
messageId?: string
}>()
</script>

View File

@@ -0,0 +1,61 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CoachmarkLanding from './CoachmarkLanding.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } }
})
function renderLanding() {
return render(CoachmarkLanding, {
props: {
title: 'Welcome to Apps',
message: 'A quick tour of the essentials.',
primaryLabel: 'Start tutorial',
skipLabel: 'Skip for now'
},
global: { plugins: [i18n] }
})
}
describe('CoachmarkLanding', () => {
afterEach(cleanup)
it('renders the title and message', async () => {
renderLanding()
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
})
it('emits start when the primary action is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(emitted().start).toHaveLength(1)
})
it('emits skip when Skip is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(
await screen.findByRole('button', { name: 'Skip for now' })
)
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await screen.findByText('Welcome to Apps')
await user.keyboard('{Escape}')
// The explicit listener and Reka's own dismiss may both fire here.
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
})
})

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