Compare commits

...

39 Commits

Author SHA1 Message Date
Connor Byrne
3a6fe052cd feat(test-framework): extension-API test suite + compat-floor gate (I-TF)
Adds the isolated vitest config, CI workflow, and all 41×3 compat-floor
stub triples so the blast_radius≥2.0 gate passes from day one.

- vitest.extension-api.config.mts — targets src/extension-api-v2/__tests__/
- .github/workflows/ci-tests-extension-api.yaml — two jobs: vitest run +
  python3 scripts/check-compat-floor.py (exits 1 on missing stubs)
- package.json — test:extension-api / :watch / :coverage scripts
- src/extension-api/ — public declaration files (NodeHandle, WidgetHandle,
  defineNodeExtension, typed events, lifecycle hooks)
- src/extension-api-v2/__tests__/v1|v2|migration — 123 stub files covering
  BC.01–BC.41 (all 34 compat-floor categories × 3 stub types)
- packages/extension-api/ — typedoc wrapper package

compat-floor gate: OK — 102 stub files present (34 categories × 3 types)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:21:54 -07:00
Christian Byrne
83263fd2ee docs(arch): pass-3 evidence merge (240→634 evidence, 56→59 patterns)
Merges three pass-3 staging sources via scripts/merge-staging-pass3.py:

  R8 (clone-grep top-20 ecosystem packs)  — 887 raw rows, 380 deduped
  R9.security                             — 3 NEW S16.* dynamic-code patterns
  R9.cookiecutter / R9.guides             — sanctioned-public surface evidence

Rollup re-ranked. Notable shifts:

  rank   pattern                                              ★sum  occ
   1     S6.A1  graphToPrompt monkey-patching ⚠️ CRITICAL    23,604  15
   2     S2.N15 prototype.serialize replacement                5,996  12
   3     S11.G2 graph add/remove/findNodesByType              21,086  12
   4     S11.G3 beforeChange/afterChange batching             18,973  13
   5     S7.G1  window.LiteGraph globals as ABI               18,853  14
   6     S6.A4  app.queuePrompt patching                       3,464  11
   7     S10.D1 dynamic addInput/Output                        5,287  11
   8     S10.D2 node.connect/disconnect programmatic          18,643  14
   9     S4.W4  widget.options.values mutation                 7,985  11
  10     S2.N16 node.widgets array access                      7,086  13

S11.G3 (beforeChange batching) and S7.G1 (window globals) entered top-5 —
both drive the v2 'three orthogonal capabilities' thesis: batched mutations,
typed import deprecation path.

3 NEW S16.* patterns from security scan:
  S16.D1 SVG with onload/onerror (XSS-equivalent dynamic JS)
  S16.D3 obfuscated string-replace pipeline
  S16.D7 Python web/ template uses exec/eval to emit JS

star-cache: 105 → 119 starred repos (added Acly, ssitu, cubiq, Fannovel16,
WASasquatch, Crystools, cg-use-everywhere, mikey_nodes, etc. — actual
top-20 ecosystem heavyweights now represented).
2026-05-08 13:39:34 -07:00
Christian Byrne
581c5bb38e feat(ext-api-v2): surface-only API shim — base for Phase A stack
Adds the v2 extension API public surface:

  src/types/extensionV2.ts          (169 lines) — branded entity IDs,
    geometry primitives, slot/widget options, NodeHandle/WidgetHandle
    contracts. Pure types. The stable public surface extensions depend on.

  src/services/extensionV2Service.ts (413 lines) — surface-only impl.
    Thin pass-throughs to existing LGraphNode/widget/canvas. No new
    internal architecture; no patching guards yet (Phase A scope per D9).
    Reactive mounting via Vue EffectScope; scope registry keyed by
    extension:entityId.

  src/extensions/core/{dynamicPrompts,imageCrop,previewAny}.v2.ts
    Three smallest core extensions ported as proof-of-concept. Used
    as the I-COORD.1 reference for Simon + Austin to push back on
    the API shape with concrete code.

Phase A goals (per todo.md "Branch + phasing topology"):
  - Stable v2 surface coworkers can branch off
  - Internal methods are pass-throughs (no behavior change)
  - I-PG.A: no interception/blocking; v1 path coexists unchanged

Stacks under this branch (will be opened as draft PRs):
  - ext-v2/i-tf-test-framework
  - ext-v2/i-sr-scope-registry
  - ext-v2/i-ws-lazy-serialize
2026-05-08 13:25:20 -07:00
Christian Byrne
4d37194be6 docs(arch): pass-2 evidence sweep (52→56 patterns, 174→195 evidence)
Adds 21 evidence rows + 4 new patterns from second MCP code-search burst:

  S11.G3 graph.beforeChange/afterChange — explicit batching seam
  S7.G1  window.LiteGraph / window.comfyAPI globals (CRITICAL)
  S11.G4 graph.setDirtyCanvas — manual redraw flush idiom
  S10.D3 node.setSize(node.computeSize()) — manual relayout idiom

Pattern S11.G4 + S10.D3 together justify v2's reactive-layout value
proposition; S11.G3 motivates a first-class world.batch() API; S7.G1
sets the deprecation sequence for window globals.

star-cache.yaml grew 87 → 105 starred repos; #1 blast-radius is still
S6.A1 graphToPrompt (★17,122) — unchanged.

Reproducible via docs/architecture/extension-api-v2/scripts/add-evidence-pass2.py
2026-05-07 17:45:40 -07:00
Connor Byrne
5bb54de0d7 docs(arch): add v2 extension API touch-point database
Compatibility-surface map for the upcoming v2 extension API redesign.
52 patterns across 15 surface families derived from:
- AGENTS.md §5 (40+ repo callouts)
- ADR 0008 entity taxonomy
- widget-api-thoughts.md output system axis
- Real-world MCP code-search across 87 ecosystem repos

See docs/architecture/extension-api-v2/README.md.
2026-05-07 17:45:40 -07:00
AustinMroz
68843967cf App Mode tests (#10633)
Adds tests for
- Mobile app mode.
- Drag and drop operations in app mode
- Basic widget interaction in app mode.
- The read only state when in builder mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10633-App-Mode-tests-3306d73d36508154aa25d8096119a32c)
by [Unito](https://www.unito.io)
2026-05-07 15:36:32 -07:00
Yourz
8c295e7c68 fix: remove transition delay from FeedbackSection progress bar (#12059)
*PR Created by the Glary-Bot Agent*

---

## Summary

The horizontal progress bar in `FeedbackSection.vue` lagged behind the
carousel scroll position because the bar's width was driven reactively
by `useScroll`, but the `transition-all duration-200` utility animated
each width change over 200ms. As scroll continuously emits new target
widths, the bar visibly trailed the scroll position.

Removing the transition makes the bar track scroll synchronously.

## Verification

- Reproduced the lag locally on `/customers`.
- Verified post-fix that `bar.style.width` updates in the same frame as
`scrollLeft` changes (samples at scrollLeft 0 / 944 / 1600 → width 0% /
59% / 100%, with `transitionDuration: 0s`).
- `pnpm exec eslint`, `pnpm exec oxfmt --check`, `pnpm nx typecheck
website`, and `pnpm test:unit` all pass.

## Notes

No regression test added — the customers section components have no
existing unit/E2E coverage in this repo, and standing up a new test
harness for a one-line CSS fix would be disproportionate. Worth
following up on broader carousel coverage as a separate task.

## Screenshot

After fix, scrolled to second slide — progress bar tracks scroll
position synchronously.

## Screenshots

![FeedbackSection progress bar synced with scroll after
fix](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/0b55db78805d95bbfa3c5dc0515c18344f0a9f490ce551057542fb86ec998de6/pr-images/1778167277854-e235c826-8368-4e15-939a-00bad6412625.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12059-fix-remove-transition-delay-from-FeedbackSection-progress-bar-3596d73d36508107bc80dc38ea7ab79e)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-07 19:31:46 +00:00
pythongosssss
219a574eed test: add failing test for deleting inner nodes with promoted widgets (#12058)
## Summary

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

## Changes

- **What**: 
- add failing test

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

---

## Summary

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

## Root cause

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

## Fix

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

## Test coverage

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

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

## Verification

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

## Out of scope

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

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

## Screenshots

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

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

---------

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

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

## Changes

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

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

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

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


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

## Changes

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12056-test-add-missing-emitModelReady-to-mock-object-3596d73d36508130a087f5c7713e932f)
by [Unito](https://www.unito.io)
2026-05-07 14:04:21 +00:00
Terry Jia
ea277dec4d FE-446: test(load3d): cover Load3D/Preview3D extensions and config persistence (#11969)
## Summary
Add unit tests for src/extensions/core/load3d.ts (Load3D and Preview3D
nodeCreated/onExecuted, beforeRegisterNodeDef, getNodeMenuItems, camera
matrix generation guard) and extend Load3DConfiguration tests for the
Scene/Camera/Light config persistence + settingStore fallback paths.

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

## Screenshots (if applicable)
before


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


after


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

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

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

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

## Changes

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

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

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

## Changes

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

## Review Focus

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

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

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

Addresses #11894

## Screenshots (if applicable)

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

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

## Changes

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

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

---

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

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

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

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

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

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

---

## Summary

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

## Files changed

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

## Verification

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

## Reviewer note (Oracle finding)

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

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

---------

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

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

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

## Changes

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

## Review Focus

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

## Tests

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

## screenshot

### AS IS 

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

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


### TO BE 

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


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


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

---

## Summary

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

## Changes

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

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

## Verification

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

## Review Focus

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

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

---------

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

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

## Screenshots (if applicable)
before


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


after


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

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

---

## Summary

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

## Change

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

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

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

## Verification

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

## Before / After (2560px viewport)

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

## Screenshots

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

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

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

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

## E2E coverage

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

**Base branch:** `main`

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

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-07 04:28:55 +00:00
jaeone94
1ab9752af8 fix: keep Reka overlays above PrimeVue dialogs (#12038)
## Summary

Temporarily patch FE-569 by keeping the affected portaled Reka dropdowns
and menus above their containing PrimeVue dialogs when PrimeVue auto
z-index state has been elevated.

## Changes

- **What**: Added a small compatibility helper,
`usePrimeVueOverlayChildStyle`, that returns an anchor ref plus a
computed inline style for child popover content. The helper finds the
nearest PrimeVue dialog mask (`.p-dialog-mask` / `.p-overlay-mask`) from
the parent surface and, only when found, applies `parent z-index + 1` to
the affected Reka overlay content.
- **What**: Applied that helper at the exact PrimeVue parent surfaces
where the issue was found. This PR does not add a global overlay policy
and does not change every Reka select/dropdown in the app.
- **What**: Added optional `contentStyle`/`selectContentStyle` plumbing
only where needed so the style reaches the actual portaled Reka overlay
root.
- **What**: Added focused unit coverage for the helper contract: no
PrimeVue parent preserves existing stacking, PrimeVue dialog/overlay
masks render child content above the parent, low parent z-index values
respect the Reka floor, and invalid z-index values do not inject an
inline override.
- **Approach**: This is intentionally a minimal, parent-scoped band-aid.
It avoids a global PrimeVue overlay scanner because global sampling can
be polluted by unrelated persistent PrimeVue roots such as Toast and
would turn this fix into a broader layering policy.
- **Approach**: The patch targets the confirmed failure mode: a Reka
child overlay rendering below its owning PrimeVue dialog after PrimeVue
autoZIndex has been elevated. It does not attempt to solve PrimeVue
z-index globally.
- **Lifecycle**: This is temporary migration compatibility. PrimeVue
dialogs and controls are being incrementally migrated to Reka UI, so
`usePrimeVueOverlayChildStyle` and the optional style props added for
FE-569 should be removed once the affected parent surfaces move to Reka.
- **Breaking**: None. New props are optional and no public API contract
is changed.
- **Dependencies**: None.

## Patched Entry Points

This PR pinpoints the six affected user-facing surfaces below. Each
patch is applied from the PrimeVue dialog parent and passed only to the
Reka child overlay content that can render underneath that parent.


https://github.com/user-attachments/assets/d0d1522a-ffc7-4934-9e7a-06b83e20f809

1. **Workflow Template Library filters**
- **How to enter**: click the Templates button in the left sidebar, or
open the Comfy menu and choose **Browse Templates**.
- **Affected elements**: the template filter popovers in
`WorkflowTemplateSelectorDialog`: **Model**, **Use case**, **Runs on**,
and **Sort by**.
- **Patch point**: `WorkflowTemplateSelectorDialog.vue` anchors to the
template dialog content filter area and passes `selectContentStyle` to
the affected `MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/3641fa24-da51-4392-a904-9085f8a5a2f4

2. **Manager dialog header controls**
- **How to enter**: open Manager from the top/menu Manager entry when
the new Manager UI is available.
- **Affected elements**: the Manager header controls in `ManagerDialog`:
search mode `SingleSelect`, search autocomplete suggestions, and
**Sort** `SingleSelect`.
- **Patch point**: `ManagerDialog.vue` anchors to the dialog header and
passes `selectContentStyle` to those three Reka overlays.


https://github.com/user-attachments/assets/cf25cc06-f851-48ef-9d9c-9ec2da8afc06

3. **Asset Browser filter bar**
- **How to enter**: open the Asset Browser from an eligible model widget
browse action, the Model Library flow, or another
`useAssetBrowserDialog` caller.
- **Affected elements**: `AssetFilterBar` controls: **File formats**,
**Base models**, **Ownership**, and **Sort by**.
- **Patch point**: `AssetBrowserModal.vue` anchors to the PrimeVue
dialog header and passes the style through `AssetFilterBar` to its
`MultiSelect` / `SingleSelect` controls.


https://github.com/user-attachments/assets/e27bd805-10c0-4b3b-97f3-9e11faa47021

4. **Asset Browser model info panel**
- **How to enter**: open Asset Browser, select an asset, then use the
right-side model info panel.
- **Affected element**: the **Model type** select in `ModelInfoPanel`.
- **Patch point**: `AssetBrowserModal.vue` reuses the same parent-scoped
style and passes it to `ModelInfoPanel` as `selectContentStyle`.


https://github.com/user-attachments/assets/5e9f7ef0-ebd7-4987-ba1b-2137c034086f

5. **Upload Model confirmation step**
- **How to enter**: open Asset Browser, click **Upload**, enter/fetch
model metadata, then proceed to the confirmation step.
- **Affected element**: the **Model type** `SingleSelect` in
`UploadModelConfirmation`.
- **Patch point**: `UploadModelConfirmation.vue` anchors within the
upload dialog content and passes `selectContentStyle` to the model type
selector.



https://github.com/user-attachments/assets/ec145f26-8621-455b-915e-bedee47e1cbd

6. **Settings > Keybinding panel controls**
- **How to enter**: open Settings from the sidebar/menu, then select the
**Keybinding** panel.
- **Affected elements**: the keybinding preset select, the preset
overflow dropdown menu, and the row context menu inside
`KeybindingPanel`.
- **Patch point**: `KeybindingPanel.vue` anchors to the settings dialog
panel and passes `keybindingOverlayContentStyle` only to those Reka
overlay roots.

## Review Focus

- Confirm the patch stays narrowly scoped to the six known PrimeVue
parent + Reka child overlay surfaces above.
- Confirm `contentStyle` reaches the actual portaled Reka overlay
content in each patched path.
- Confirm the fallback behavior preserves existing stacking when no
PrimeVue parent overlay is found; in that case the helper returns an
empty style object and leaves existing Tailwind z-index classes alone.
- Please avoid expanding this into a larger overlay refactor. The goal
is a clean, backport-friendly compatibility patch while the Reka
migration continues.

Validation performed:

- `pnpm exec vitest run src/composables/usePopoverSizing.test.ts`
- `pnpm typecheck`
- `pnpm lint` (passes with existing unrelated warnings only)
- `pnpm format:check`
- commit hook lint-staged checks (`oxfmt`, `stylelint`, `oxlint`,
`eslint --fix`, `pnpm typecheck`)
- pre-push `pnpm knip`

Linear: FE-569

## Bug Screenshots 



https://github.com/user-attachments/assets/e73761af-9867-4c50-ab0d-4e32e59011e1



https://github.com/user-attachments/assets/145daf4d-3268-428b-9987-1e1afd0b866f


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12038-fix-keep-Reka-overlays-above-PrimeVue-dialogs-3596d73d365081e7af49dbc4d3905962)
by [Unito](https://www.unito.io)
2026-05-07 13:37:08 +09:00
Dante
e469611f6d perf: memoize asset display transform across filter tab switches (#11491)
## Root cause

`useAssetBrowser`'s `filteredAssets` computed re-ran
`.map(transformAssetForDisplay)` over the full result set on every tab
switch. `transformAssetForDisplay` allocates fresh `badges`/`stats`
objects, walks `tags`, calls `getAssetBaseModels`, and runs i18n date
formatting per asset — none of which were memoized. Switching All /
Inputs / Outputs forced N transforms per click and produced brand-new
`AssetDisplayItem` references, which also defeated `:key`-based diffing
in `AssetGrid` / `VirtualGrid` and re-rendered every visible card.

## Fix

Memoize `transformAssetForDisplay` at module scope with a
`WeakMap<AssetItem, AssetDisplayItem>`. Unchanged assets reuse the same
display item across tab switches; the GC reclaims entries when assets
are released.

## Before / after (n=200 assets, 6 tab switches: inputs → outputs → all
→ inputs → outputs → all)

| Metric                          | Before | After |
| ------------------------------- | -----: | ----: |
| `transformAssetForDisplay` runs |    800 |     0 |
| Wall time (Vitest harness)      | 13.2 ms | 8.1 ms |
| Reused `AssetDisplayItem` refs  |      0 |   200 |

Measured via
`src/platform/assets/composables/useAssetBrowser.perf.test.ts` plus a
temporary `process.stderr.write` harness.

## Red / green

| Commit    | Purpose |
| --------- | ------- |
| 7367fdd60 | test: failing perf assertions (transform budget + reused
refs) |
| 021b98ac0 | perf: WeakMap memoization of `transformAssetForDisplay` |

## Test plan

- [x] `pnpm exec vitest run
src/platform/assets/composables/useAssetBrowser` — 42/42 pass (including
2 new perf assertions)
- [x] `pnpm typecheck`
- [x] `pnpm exec eslint` on touched files
- [x] `pnpm exec oxfmt --check` on touched files

Fixes [FE-229

](https://linear.app/comfyorg/issue/FE-229/asset-browser-switching-all-inputs-outputs-tabs-is-slow)Source:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776716352588229

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11491-perf-memoize-asset-display-transform-across-filter-tab-switches-3496d73d36508112822dd6e7b58040fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-07 13:03:57 +09:00
Christian Byrne
ad6cbf7cbe feat: align cloud batch count limit with server-side queue cap (#11876)
*PR Created by the Glary-Bot Agent*

---

Raises `Comfy.QueueButton.BatchCountLimit` on cloud from `32` to `100`
to match the server-side `MaxQueuedJobsPerUser` cap
(`cloud/infrastructure/dynamicconfig/prod/config.json:3`). The desktop
default was already `100` and is unchanged — collapsing both branches to
the same constant.

Addresses Discord feature request: [Increase queue batch limit from
200](https://discord.com/channels/1218270712402415686/1243609826299220039/1499104231381012641).

## Change

```diff
-    defaultValue: isCloud ? 32 : 100,
+    defaultValue: 100,
```

The setting is read dynamically by all batch count UIs
(`BatchCountEdit.vue`, `LinearControls.vue`).

## Why 100 (not 512)

Original ask was 200→512. Investigation showed:

- The actual previous default was `100` (desktop) / `32` (cloud), not
200.
- Cloud enforces `MaxQueuedJobsPerUser = 100` per workspace server-side.
A higher frontend cap can't unlock more queued work — extra prompts just
get rejected with `QUEUE_LIMIT`.
- Frontend submits prompts as N sequential `POST /prompt` calls (no
batched-prompt endpoint), so the UI cap is purely about how many clicks
it takes — not throughput.
- Going from 32 → 100 lets cloud users match the server cap in one click
instead of 4. No new behavior is unlocked.

## Known limitation (pre-existing, not introduced here)

The new max equals the absolute server cap, not the user's remaining
capacity. A user with already-queued work can hit `QUEUE_LIMIT`
mid-batch. The pre-existing 32 limit had the same shape (just at a
smaller scale); deriving the UI max from `cap - outstanding` would
require polling and reactive state and is out of scope for a one-line
setting bump.

## Verification

- `pnpm typecheck` — passes
- `pnpm lint` — 0 errors (1 pre-existing warning in unrelated test file)
- `pnpm test:unit` — `BatchCountEdit.test.ts` (3 tests) +
`src/platform/settings/**` (70 tests) all pass
- **Manual (Playwright)**:
- `settingStore.get('Comfy.QueueButton.BatchCountLimit')` returns `100`
at runtime
  - Typing `999` into the batch count widget clamps to `100`
  - Increment button is disabled at `100` (max reached)

## Screenshots

![Queue batch count widget set to 100 (the new max). Increment button is
disabled because the limit was
reached.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6a01e1ea573fa88f163fb64768a619d250a0b5da26b04249929b8734e04dac57/pr-images/1777863864881-dcc0e33d-e5e7-4a12-9cf3-89af60fd12f6.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11876-feat-align-cloud-batch-count-limit-with-server-side-queue-cap-3566d73d3650819b8d01dbf83d1a8e49)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-06 23:19:32 +00:00
Kelly Yang
5ebf5e03ae refactor(load3d): replace PrimeVue Select/Slider/Checkbox with Reka UI (#12020)
Replace PrimeVue components in 3D node viewer controls with the
project's Reka UI equivalents across 7 files.

## Changes

| File | Replaced |
|------|---------|
| `AnimationControls.vue` | `Select` × 2 (speed + animation) |
| `ViewerModelControls.vue` | `Select` × 2 (up direction + material
mode) |
| `ViewerCameraControls.vue` | `Select` + `Slider` (camera type + FOV) |
| `ViewerExportControls.vue` | `Select` (export format) |
| `PopupSlider.vue` | `Slider` |
| `ViewerLightControls.vue` | `Slider` |
| `ViewerSceneControls.vue` | `Checkbox` → native `<input
type="checkbox">` |

## Implementation notes

- `Select` uses `@/components/ui/select/*` compound components. Numeric
model values (animation speed index) are stringified at the binding
boundary and converted back on update, matching Reka `SelectRoot`'s
`string`-only `modelValue` contract.
- `Slider` uses `@/components/ui/slider/Slider.vue`. Single-number
`defineModel` values are wrapped in a `computed` array and unwrapped in
the update handler, following the pattern established in
`LightControls.vue`.
- No new Reka UI wrapper components were created — existing ui/select
and ui/slider primitives were used directly.

## Test 

https://github.com/user-attachments/assets/afca0fc8-a7b6-49ee-b221-ee5725bd127e
1. AnimationControls.vue
- **Add Load3D node** → Upload an animated GLB file (e.g., a character
model).
- **Node preview top bar:** Play/Pause button, speed dropdown, animation
name dropdown, and progress bar.

2. PopupSlider.vue
- **Hover over Load3D preview:** Icon buttons appear in the left
toolbar.
- **"Light Intensity" button (bulb icon)** → Slider pops up on the
right.
- **"FOV" button (view icon)** → Slider pops up on the right.

3. ViewerCameraControls.vue
- **Load3D node** → Settings panel (top-right) → **"Camera"** tab.
- **Features:** Camera type dropdown (Perspective / Orthographic), FOV
slider (visible in Perspective mode).

4. ViewerExportControls.vue
- **Settings panel** → **"Export"** tab.
- **Features:** Format dropdown (GLB / OBJ / STL), Export button.

5. ViewerLightControls.vue
- **Settings panel** → **"Light"** tab.
- **Features:** Light intensity slider.

6. ViewerModelControls.vue
- **Settings panel** → **"Model"** tab.
- **Features:** "Up direction" dropdown, Material mode dropdown
(Wireframe / Normal, etc.).

7. ViewerSceneControls.vue
- **Settings panel** → **"Scene"** tab.
- **Features:** Background color picker, "Show grid" checkbox, upload
background image button.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> UI component swap touches multiple interactive viewer controls
(selects/sliders/checkbox), so small binding/typing differences (string
vs number, array slider values) could cause subtle regressions despite
test updates.
> 
> **Overview**
> Replaces PrimeVue `Select`, `Slider`, and `Checkbox` usages across
Load3D viewer controls with the project’s Reka UI-based primitives
(`@/components/ui/select/*`, `@/components/ui/slider/Slider.vue`) and a
native checkbox.
> 
> Updates v-model wiring to match the new components’ contracts: selects
now bind via string `modelValue` with explicit number casting where
needed, and sliders now wrap single numeric values into `[number]`
arrays with corresponding update handlers. Unit tests are updated to
mock the new UI components and their updated event/value shapes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
46f99db256. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12020-refactor-load3d-replace-PrimeVue-Select-Slider-Checkbox-with-Reka-UI-3586d73d365081f58601d93031016afd)
by [Unito](https://www.unito.io)
2026-05-06 19:30:25 -04:00
Benjamin Lu
d3ab2be695 test: reuse queue button page object in e2e (#11927)
## Summary

Reuse the actionbar queue-button page object in the queue mode E2E tests
so dropdown selectors live in one helper.

## Changes

- **What**: Adds queue mode menu/item helpers to
`ComfyActionbar.queueButton` and updates `queueButtonModes.spec.ts` to
use them.
- **Dependencies**: None.

## Review Focus

This is stacked on #11209 and should be reviewed as a test-infra cleanup
only; the behavior covered by the spec is unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11927-test-reuse-queue-button-page-object-in-e2e-3566d73d365081918d59c2d587c4c94a)
by [Unito](https://www.unito.io)
2026-05-06 15:07:57 +00:00
pythongosssss
37f0fbcbef fix: add guard to prevent user store re-initialization (#11959)
## Summary

Make `userStore.initialize()` idempotent and concurrency-safe so the
bootstrap, router-guard, and UserSelectView callers share a single
getUserConfig fetch instead of racing/duplicaitng calls.

## Changes

- **What**: 
- cache initialize in a promise so callers all re-use the same result
- remove now redundant is initialized guard
- tests

## Review Focus
- Current user switch/logout uses `window.location.reload()`, no callers
intentionally call initialize to reinit. In future if this changes we
may want to add a parameter to skip the cache or a separate function.
- Failed initializes are not cached to allow callers to retry

- Not practical for e2e tests, the unit tests prove that the requests
are deduped. All a e2e test would do is mock/spy on the network requests
to show multiple requests do not happen - which the unit tests do a
better job of.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11959-fix-add-guard-to-prevent-user-store-re-initialization-3576d73d3650817db7b0e52cc25f9b7b)
by [Unito](https://www.unito.io)
2026-05-06 12:57:36 +00:00
Terry Jia
6ef051f200 FE-537: fix(load3d): preserve camera view, fit transform, and first-frame paint after refresh (#11944)
## Summary

- Defer thumbnail capture until camera state is restored via new
modelReady event so captureThumbnail no longer races with the saved
view, fixing the "snap back to default on hover" regression.
- Repaint the live scene at the end of captureThumbnail so the canvas is
not left with the offscreen mask/normal pass when the render loop is
gated.
- Persist post-fitToViewer model.scale + model.position into the
existing modelConfig.gizmo slot so a refresh reapplies them via the
existing applyGizmoConfigToLoad3d path; rotation stays owned by
upDirection.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11944-FE-537-fix-load3d-preserve-camera-view-fit-transform-and-first-frame-paint-after-re-3576d73d365081429653ea4740612617)
by [Unito](https://www.unito.io)
2026-05-06 08:43:54 -04:00
Dante
0788e71394 feat(dialog): introduce Reka-UI dialog primitives + opt-in renderer branch (Phase 0) (#11719)
## Summary

Lands the renderer infrastructure for migrating ComfyUI Frontend's
central dialog system from PrimeVue to Reka-UI. **Phase 0 of a phased
migration.** No production dialog migrates in this PR — every existing
dialog continues to render through PrimeVue exactly as before.

## Motivation

GitHub issue #11688 surfaced a PrimeVue Dialog `max-width` design
limitation that is awkward to address through PrimeVue's pass-through
styling. ADR 0004 (Rejected, 2025-08-27) explicitly endorses **selective
component replacement with shadcn/Reka-UI** as the path forward for
problematic PrimeVue components, and `AGENTS.md` already directs
contributors to "Avoid new usage of PrimeVue components." The dialog
system is a strong first candidate: clean public API boundary
(`useDialogService` / `dialogStore`), bounded surface (~12 dialogs), and
Reka-UI is already in use elsewhere in the codebase. The #11688 fix
arrives naturally in Phase 1 once `prompt`/`confirm` migrate to the new
primitive's `md` default (`max-width: 36rem`).

## Phased migration plan

This PR is **Phase 0 only**. Each subsequent phase is shipped as its own
PR.

| Phase | Scope | Approx LOC |
| ----- | ----- | ---------- |
| **0 (this PR)** | Reka-UI primitive set under
`src/components/ui/dialog/` + opt-in renderer branch in
`GlobalDialog.vue` + tests + Storybook | ~600 |
| 1 | Migrate `PromptDialogContent` + `ConfirmationDialogContent`;
closes #11688 | ~250 |
| 2 | Migrate `ErrorDialogContent`, `NodeSearchBox(Popover)`,
`SecretFormDialog`, `VideoHelpDialog`, `CustomizationDialog` | ~400 |
| 3 | Migrate Settings dialog (workspace + non-workspace variants) —
designer review | ~300 |
| 4 | Migrate Manager dialog — designer review | ~300 |
| 5 | Migrate `ConfirmDialog` callers (`SecretsPanel`,
`BaseWorkflowsSidebarTab`) | ~150 |
| 6 | Remove PrimeVue Dialog/ConfirmDialog imports + clean up CSS
overrides | ~200 |

Full plan in `temp/plans/dialog-migration-phase-0.md` and ADR draft at
`temp/plans/adr-0009-dialog-reka-migration-DRAFT.md` (will move to
`docs/adr/` after team review).

## Changes

- **What**:
- New shadcn-style primitives at `src/components/ui/dialog/` wrapping
Reka-UI's `Dialog*` components: `Dialog`, `DialogPortal`,
`DialogOverlay`, `DialogContent`, `DialogHeader`, `DialogFooter`,
`DialogTitle`, `DialogDescription`, `DialogClose`. Variants via `cva`
with sizes `sm | md | lg | xl | full`.
- `dialogStore.CustomDialogComponentProps` gains opt-in `renderer?:
'primevue' | 'reka'` (default `'primevue'`) and `size?: 'sm' | 'md' |
'lg' | 'xl' | 'full'`.
- `GlobalDialog.vue` branches the per-stack-item template based on the
`renderer` flag. PrimeVue path is byte-identical to before.
  - Storybook stories: `Default`, `LongContent`, `Headless`, `AllSizes`.
- Unit tests verifying branch selection and that the opt-in flag is
preserved on the dialog stack item.
- **Breaking**: None. Default renderer is `primevue` and no production
dialog opts in.
- **Dependencies**: None. Reka-UI is already a workspace dependency.

## Review Focus

1. **API surface**: `useDialogService` / `dialogStore` public API is
unchanged. Custom-node extensions calling
`app.extensionManager.dialog.*` continue to work.
2. **Renderer branch wiring** in `GlobalDialog.vue` — `escape-key-down`
/ `pointer-down-outside` map to `closeOnEscape` / `dismissableMask`;
`mousedown` calls `dialogStore.riseDialog` to mirror the PrimeVue
PT-based behavior.
3. **Primitive defaults** — `md` size = 36rem max-width (chosen to
resolve #11688 in Phase 1); `full` = `calc(100vw - 1rem)` escape hatch
for Settings/Manager later.
4. **No behavior change**: existing dialogs continue to render unchanged
because nothing opts into `renderer: 'reka'` in this PR.

## Quality gates

- `pnpm typecheck` — clean
- `pnpm lint` — clean (1 pre-existing warning unrelated to this PR)
- `pnpm test:unit` — 48 dialog-adjacent tests pass including 3 new tests
in `GlobalDialog.test.ts`
- `pnpm format` — applied

knip pre-push noise (unused deps in workspace packages, unused
`types.gen.ts`) is pre-existing on `main` and not introduced by this PR.

## Out of scope (deferred)

- Migrating any production dialog — Phase 1+
- Removing PrimeVue dependency — Phase 6
- Touching legacy `ComfyDialog` (`src/scripts/ui/dialog.ts`) — separate
cleanup
- Deduplicating `Dialogue.vue` / `ImageLightbox.vue` against the new
primitives — separate cleanup

Refs #11688

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11719-feat-dialog-introduce-Reka-UI-dialog-primitives-opt-in-renderer-branch-Phase-0-3506d73d365081fc8c83ceadbffd276c)
by [Unito](https://www.unito.io)


# test

## checklist

| Scenario | Cloud prod | This PR | Notes |
  |---|---|---|---|
| Confirm dialog (delete/sign‑out) |  |  | OK/Cancel, ESC, backdrop
click identical |
| Prompt dialog (rename / save as) |  |  | Enter submits, ESC cancels,
focus trap intact |
| Settings dialog open/close |  |  | Tabs, search, ESC, save
persistence unchanged |
| Manager dialog |  |  | Tab switching, sub‑confirm stacking, z‑index
correct |
| Stacked dialog ESC handling |  |  | Only top dialog closes;
mousedown raises bottom |
| Dialog after route change |  |  | No orphaned overlay, no body
scroll lock leak |
| `.p-dialog` DOM attrs | clean | clean | No `renderer=` / `size=`
attribute leak from new optional fields |


## screenshot 
<img width="1616" height="927" alt="Screenshot 2026-05-06 at 8 43 10 PM"
src="https://github.com/user-attachments/assets/c6f668c2-a537-45ae-bf66-8bb0617502de"
/>
<img width="1419" height="951" alt="Screenshot 2026-05-06 at 8 43 41 PM"
src="https://github.com/user-attachments/assets/d82d4b27-cb05-4185-be4a-bd2fb9503130"
/>
<img width="1884" height="1001" alt="Screenshot 2026-05-06 at 8 46
31 PM"
src="https://github.com/user-attachments/assets/dd13f99f-a11e-4b85-9f27-7d30c55cf266"
/>
<img width="1876" height="1009" alt="Screenshot 2026-05-06 at 8 47
29 PM"
src="https://github.com/user-attachments/assets/f9824b57-4a06-44d6-8f18-e1226c764c83"
/>
2026-05-06 12:04:15 +00:00
Yourz
d3f802de10 feat(pricing): add concurrent API jobs feature to Creator and Pro tiers (#12000)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a new feature bullet to the Creator and Pro plans on the [cloud
pricing page](https://comfy.org/cloud/pricing) to call out included API
concurrency:

- **Creator**: `3 concurrent API jobs`
- **Pro**: `5 concurrent API jobs`

Free and Standard tiers do not include API access, so they are not
changed.

This matches the language landing in the docs PR:
[Comfy-Org/docs#965](https://github.com/Comfy-Org/docs/pull/965).

## Changes

- `apps/website/src/components/pricing/PriceSection.vue`: added
`feature2` to the Creator and Pro plan feature lists.
- `apps/website/src/i18n/translations.ts`: added
`pricing.plan.creator.feature2` and `pricing.plan.pro.feature2` for `en`
and `zh-CN`.
- Updated `pricing-tiers-{1-sm,2-md,3-lg,4-xl}` visual regression
snapshots in `apps/website/e2e/visual-responsive.spec.ts-snapshots/` to
match the new copy.

## Verification

- `pnpm nx run @comfyorg/website:typecheck` — clean
- ESLint and `oxfmt` clean on changed files (pre-commit lint-staged also
passed)
- `pnpm exec playwright test --project visual -g "pricing-tiers"` — 4/4
passing against the regenerated snapshots
- Manually rendered `localhost:4321/cloud/pricing`; confirmed copy
appears in both desktop and mobile layouts and that Free / Standard are
unchanged. Screenshots below.

## Screenshots

### Desktop
![desktop](pricing-desktop)

### Mobile — Creator
![mobile creator](pricing-mobile-creator)

### Mobile — Pro
![mobile pro](pricing-mobile-pro)


## Screenshots


![pricing-desktop](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290283-b5f959c8-b0ba-42bf-a822-aa7c04b5a3dc.png)


![pricing-mobile-creator](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029290725-2cf36ff3-bc0d-4746-b37d-e3445f4293ab.png)


![pricing-mobile-pro](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9b9dd9686537a805551dda7b053b16b57fd91a2318efa5c698213337619cb9d2/pr-images/1778029291135-5fe2f975-a0ac-4aac-91bc-5602670d8bbf.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12000-feat-pricing-add-concurrent-API-jobs-feature-to-Creator-and-Pro-tiers-3586d73d365081559acfc44eb5024c52)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-06 11:46:32 +00:00
Kelly Yang
d78c630d36 test(maskeditor): expand useBrushDrawing behavioral coverage (#12001)
Adds targeted behavioral tests for the slimmed `useBrushDrawing`
orchestration composable (Phase E of the brush-drawing refactor).

## Changes

- 5 new tests covering previously untested branches:
  - `compositeStroke` receives `isRgb=true` when active layer is `rgb`
  - `compositeStroke` receives `isErasing=true` when tool is `eraser`
  - Mask canvas opacity is restored after drawing on the mask layer
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when tool is eraser
- `globalCompositeOperation` is set to `destination-out` during
`handleDrawing` when right mouse button is held

## Coverage (useBrushDrawing.ts)

| Metric | Before | After |
|--------|--------|-------|
| Statements | 86.33% | 87.05% |
| Branches | 68.75% | 70.00% |
| Functions | 90.00% | 90.00% |
| Lines | 89.23% | 90.00% |

All 18 tests pass. GPU paths remain `/* c8 ignore */` excluded
(untestable without WebGL).

- Fixes #0

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to unit tests, adding coverage for
eraser/right-click composition and `drawEnd` GPU compositing/opacity
restoration paths without altering production logic.
> 
> **Overview**
> Adds new `useBrushDrawing` test cases to cover previously untested
branches: setting `globalCompositeOperation` to `destination-out` during
`handleDrawing` when erasing (tool or right-click), and verifying
`drawEnd` passes correct `isRgb`/`isErasing` flags to
`gpu.compositeStroke`.
> 
> Also asserts mask-layer opacity is restored after `drawEnd`,
increasing behavioral coverage around stroke completion and canvas
visibility cleanup.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
20c411e6ce. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12001-test-maskeditor-expand-useBrushDrawing-behavioral-coverage-3586d73d365081388ebcef91c2172c0a)
by [Unito](https://www.unito.io)
2026-05-06 06:15:54 -04:00
Christian Byrne
aa4343a98b test: add perf test for subgraph transition bottleneck (#10480)
## Summary

Add a `@perf` test measuring the cost of entering a subgraph containing
80 interior nodes. Establishes a CI baseline for the synchronous
mount/unmount bottleneck.

## Changes

- **What**: Add `subgraph transition (enter and exit)` perf test to
`performance.spec.ts` and a test workflow asset
(`large-subgraph-80-nodes.json`) with a single subgraph node containing
80 Note nodes.

## Review Focus

This is PR 1 of 2. The test establishes a baseline on main so the
optimization PR (PR 2) can show a CI-proven delta for `taskDurationMs`
and `totalBlockingTimeMs`.

The test:
1. Loads the 80-node subgraph workflow
2. Enters and exits once to warm up
3. Measures a fresh enter transition (start → 80 nodes mounted → layout
settled)
4. Records `taskDurationMs`, `layouts`, and `TBT`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10480-test-add-perf-test-for-subgraph-transition-bottleneck-32d6d73d3650811b9b6eec03a9591f82)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-06 01:54:18 -07:00
Benjamin Lu
270c7e34f4 fix: hide Google free-tier copy in webviews (#11924)
Stacked on #10699.

- Hide Google-specific free-tier promo copy when embedded webviews block
Google SSO.
- Use GitHub-only fallback copy when returning from email signup in that
state.
- Remove the unused export from the Google SSO blocked-reason type so
knip stays clean.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11924-fix-hide-Google-free-tier-copy-in-webviews-3566d73d36508168be7ed28cbe455d9f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-05-06 01:49:40 -07:00
Christian Byrne
666684e6e6 fix: stop PreviewAny widgets from triggering re-execution (#12010)
## Summary

Preview as Text (`PreviewAny`) nodes were re-executing on every prompt
submission because the rendered preview text was being echoed back to
the backend as input values, mutating the cache signature.

## Changes

- **What**: Set `widget.options.serialize = false` on the three widgets
the `Comfy.PreviewAny` extension adds (`preview_markdown`,
`preview_text`, `previewMode`) so they are excluded from the API prompt
sent to the backend.

## Root cause

The extension was setting `widget.serialize = false`, which only
controls **workflow JSON** persistence (checked in
`LGraphNode.serialize`). The **API prompt** serializer in
`executionUtil.graphToPrompt` checks `widget.options.serialize` instead
— a distinct property documented in litegraph's `WIDGET_SERIALIZATION`
convention.

After `onExecuted` writes the rendered text into the widget value, the
next `graphToPrompt` call serialized that text into
`inputs.preview_text` / `inputs.preview_markdown`. The backend cache
signature in `comfy_execution/caching.py` hashes all keys in
`node["inputs"]`, so the changing text invalidated the cache and forced
a redundant execution every time.

## Review Focus

Two commits, red-green TDD:
1. `test:` failing unit + e2e tests asserting the desired behavior.
2. `fix:` adds `options.serialize = false` to make them pass.

Tests verify the widgets are excluded from the API prompt; e2e
additionally simulates a prior execution populating widget values to
mirror the real bug condition.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12010-fix-stop-PreviewAny-widgets-from-triggering-re-execution-3586d73d3650810585cdd077f3ac64f5)
by [Unito](https://www.unito.io)
2026-05-06 05:08:35 +00:00
Christian Byrne
4484b62854 test: add E2E tests for queue button modes (#11209)
## Changes

Add Playwright E2E tests for queue button modes in the topbar (6 tests):

- Run button visibility in topbar
- Queue mode trigger menu visibility
- Opening the mode menu via trigger click
- Verifying available modes are shown as menu item radios
- Menu closes after selecting a mode
- Run button sends prompt when clicked (via intercepted `/api/prompt`
route)

## Test

```bash
pnpm test:browser:local -- browser_tests/tests/queueButtonModes.spec.ts
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11209-test-add-E2E-tests-for-queue-button-modes-3416d73d365081a5bf10f8b9c6bdc2a7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-06 03:30:32 +00:00
449 changed files with 37142 additions and 559 deletions

View File

@@ -54,10 +54,14 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
- name: Upload perf metrics
if: always()

View File

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

View File

@@ -0,0 +1,88 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Check compat floor
run: python3 scripts/check-compat-floor.py
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

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

View File

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

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
class="bg-primary-comfy-yellow h-full rounded-full"
:style="{ width: progressPercent }"
/>
</div>

View File

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

View File

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

View File

@@ -77,7 +77,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [{ text: 'pricing.plan.creator.feature1' }],
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
isPopular: true
},
{
@@ -90,7 +93,10 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [{ text: 'pricing.plan.pro.feature1' }]
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
},
{
id: 'enterprise',

View File

@@ -1119,6 +1119,10 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1143,6 +1147,10 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,9 @@ export class ComfyPage {
/** Worker index to test user ID */
public readonly userIds: string[] = []
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
public isVueNodes = false
/** Test user ID for the current context */
get id() {
return this.userIds[comfyPageFixture.info().parallelIndex]
@@ -352,6 +355,12 @@ export class ComfyPage {
await nextFrame(this.page)
}
async idleFrames(count: number) {
for (let i = 0; i < count; i++) {
await this.nextFrame()
}
}
async delay(ms: number) {
return sleep(ms)
}
@@ -494,6 +503,7 @@ export const comfyPageFixture = base.extend<{
comfyPage.userIds[parallelIndex] = userId
const isVueNodes = testInfo.tags.includes('@vue-nodes')
comfyPage.isVueNodes = isVueNodes
try {
await comfyPage.setupSettings({

View File

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

View File

@@ -39,10 +39,32 @@ class ComfyQueueButton {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.actionbar.page)
}
public async openOptions() {
const options = new ComfyQueueButtonOptions(this.actionbar.page)
if (!(await options.menu.isVisible())) {
await this.dropdownButton.click()
}
return options
}
}
class ComfyQueueButtonOptions {
constructor(public readonly page: Page) {}
public readonly menu: Locator
public readonly modeItems: Locator
constructor(public readonly page: Page) {
this.menu = page.getByRole('menu')
this.modeItems = this.menu.getByRole('menuitem')
}
public modeItem(name: string) {
return this.menu.getByRole('menuitem', { name, exact: true })
}
public async selectMode(name: string) {
await this.modeItem(name).click()
}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {

View File

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

View File

@@ -0,0 +1,12 @@
import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
constructor(public readonly root: Locator) {
this.selection = root.locator('button span span')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}
}

View File

@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
export class AppModeHelper {
readonly steps: BuilderStepsHelper
readonly footer: BuilderFooterHelper
readonly mobile: MobileAppHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
@@ -60,13 +62,16 @@ export class AppModeHelper {
public readonly vueNodeSwitchDismissButton: Locator
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
/** The main content area where outputs are displayed*/
public readonly centerPanel: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.mobile = new MobileAppHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
@@ -125,6 +130,7 @@ export class AppModeHelper {
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
TestIds.appMode.vueNodeSwitchDontShowAgain
)
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
}
private get page(): Page {

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class MobileAppHelper {
private readonly page: Page
readonly contentPanel: Locator
readonly navigation: Locator
readonly navigationTabs: Locator
readonly view: Locator
readonly workflows: Locator
constructor(comfyPage: ComfyPage) {
this.page = comfyPage.page
this.view = this.page.getByTestId(TestIds.linear.mobile)
this.contentPanel = this.page.getByRole('tabpanel')
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
this.navigationTabs = this.navigation.getByRole('tab')
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
}
async switchWorkflow(workflowName: string) {
await this.workflows.click()
await this.page.getByRole('menu').getByText(workflowName).click()
}
async navigateTab(name: 'run' | 'outputs' | 'assets') {
await this.navigation.getByRole('tab', { name }).click()
}
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
for (let i = 0; i < count; i++) await locator.tap()
}
}

View File

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

View File

@@ -144,6 +144,14 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export class VueNodeFixture {
public readonly collapseButton: Locator
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -23,6 +24,7 @@ export class VueNodeFixture {
this.collapseButton = locator.getByTestId('node-collapse-button')
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
}
async getTitle(): Promise<string> {
@@ -39,6 +41,16 @@ export class VueNodeFixture {
await this.collapseButton.click()
}
/**
* Select this node and delete it via the Delete key, waiting for the node
* element to leave the DOM before resolving.
*/
async delete(): Promise<void> {
await this.header.click()
await this.header.press('Delete')
await this.locator.waitFor({ state: 'hidden' })
}
async getCollapseIconClass(): Promise<string> {
return (await this.collapseIcon.getAttribute('class')) ?? ''
}

View File

@@ -0,0 +1,154 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
//an app without an image input will load the workflow
await test.step('App without an image input loads workflow', async () => {
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
await expect(centerPanel).toBeHidden()
})
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
await expect(centerPanel).toBeVisible()
await expect(imageInput.root).toBeVisible()
})
await test.step('Dragging an image redirects to image input', async () => {
const initialImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropExternalResource({
fileName: 'workflow.webp',
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
await expect(imageInput.selection).not.toHaveText(initialImage)
await expect(
centerPanel,
'A file with workflow should not open a new workflow'
).toBeVisible()
})
await test.step('Dragging a url redirects to image input', async () => {
const secondImage = await imageInput.selectedItem()
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
preserveNativePropagation: true
})
comfyFiles.deleteAfterTest({
filename: 'og-image.png',
type: 'input'
})
await expect(imageInput.selection).not.toHaveText(secondImage)
})
})
test('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['3', 'seed'],
['3', 'sampler_name'],
['6', 'text']
])
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
exact: true
})
const { input, incrementButton, decrementButton } =
comfyPage.vueNodes.getInputNumberControls(seed)
const initialValue = Number(await input.inputValue())
await seed.dragTo(incrementButton, { steps: 5 })
const intermediateValue = Number(await input.inputValue())
expect(intermediateValue).toBeGreaterThan(initialValue)
await seed.dragTo(decrementButton, { steps: 5 })
const endValue = Number(await input.inputValue())
expect(endValue).toBeLessThan(intermediateValue)
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
exact: true
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')
//verify values are consistent with litegraph
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
await expect(mobile.view).toBeVisible()
await expect(mobile.navigation).toBeVisible()
await mobile.navigateTab('assets')
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
const buttons = await mobile.navigationTabs.all()
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
await mobile.navigateTab('run')
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
const steps = comfyPage.page.getByRole('spinbutton')
const initialValue = Number(await steps.inputValue())
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'increment' }),
{ count: 5 }
)
await expect(steps).toHaveValue(String(initialValue + 5))
await mobile.tap(
comfyPage.page.getByRole('button', { name: 'decrement' }),
{ count: 3 }
)
await expect(steps).toHaveValue(String(initialValue + 2))
})
test('workflow selection', async ({ comfyPage }) => {
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
for (const name of widgetNames)
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
const widgets = comfyPage.appMode.linearWidgets
await comfyPage.appMode.mobile.navigateTab('run')
for (let i = 0; i < widgetNames.length; i++) {
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
}
})
})
})

View File

@@ -0,0 +1,121 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
const prompts = comfyPage.vueNodes
.getNodeByTitle('New Subgraph')
.locator('.lg-node-widget')
const count = await prompts.count()
for (let i = 0; i < count; i++) {
await expect(prompts.nth(i)).toBeVisible()
await prompts.nth(i).click()
await expect(items).toHaveCount(i + 1)
}
})
test('Can select outputs', async ({ comfyPage }) => {
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.appMode.select.inputItems
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
//Manually set error state on checkpoint loader
//Shouldn't be needed on ci, but has spotty reliability
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const items = comfyPage.appMode.select.inputItems
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget(
'Load Checkpoint',
'ckpt_name'
)
await expect(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
await expect(items).toHaveCount(0)
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is initially editable'
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await ksampler.header.dblclick({ force: true })
await expect(
ksampler.titleEditor.input,
'Double clicking node titles will not initiate a rename'
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toHaveCount(1)
})
})

View File

@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
})
test('GET /assets filters by exclude_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_INPUT_IMAGE),
withAsset({
...STABLE_INPUT_IMAGE,
id: 'missing-input',
tags: ['input', 'missing']
})
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
)
const data = body as { assets: Array<{ id: string }> }
expect(data.assets.map((asset) => asset.id)).toEqual([
STABLE_INPUT_IMAGE.id
])
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage,
assetApi

View File

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

View File

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

View File

@@ -351,6 +351,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test(
'subgraph transition (enter and exit)',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }, testInfo) => {
// Heaviest perf test: loads an 80-node subgraph and pays ~30s/repeat.
// The signal is dominated by N=80 mount cost, so a single sample per
// CI invocation is sufficient — early-return on subsequent repeats.
if (testInfo.repeatEachIndex > 0) return
// Load workflow with a subgraph containing 80 interior nodes.
// Entering the subgraph unmounts root nodes and mounts all 80 interior
// nodes synchronously — this is the bottleneck we're measuring.
await comfyPage.workflow.loadWorkflow('subgraphs/large-subgraph-80-nodes')
await comfyPage.idleFrames(30)
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
// Exit back to root graph before measuring a fresh enter/exit cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.idleFrames(10)
// Start measuring the enter transition
await comfyPage.perf.startMeasuring()
await comfyPage.vueNodes.enterSubgraph()
await comfyPage.vueNodes.waitForNodes(80)
await comfyPage.idleFrames(30)
const m = await comfyPage.perf.stopMeasuring('subgraph-transition-enter')
recordMeasurement(m)
console.log(
`Subgraph enter (80 nodes): ${m.taskDurationMs.toFixed(0)}ms task, ${m.layouts} layouts, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
}
)
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -0,0 +1,42 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
// Simulate a previous execution: backend returned text and the frontend
// populated the preview widget values. The next prompt submission must
// NOT echo those values back as inputs (which would change the cache
// signature and trigger a redundant re-execution).
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'PreviewAny')!
for (const widget of node.widgets ?? []) {
if (widget.name?.startsWith('preview_')) {
widget.value = 'rendered preview content from previous execution'
}
}
})
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
api: true
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()
expect(previewEntry!.inputs).not.toHaveProperty('preview_markdown')
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
})
})

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const queueModeLabels = ['Run', 'Run (On Change)', 'Run (Instant)']
const runOnChangeLabel = queueModeLabels[1]
test.describe('Queue button modes', { tag: '@ui' }, () => {
test('Run button is visible in topbar', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.primaryButton).toBeVisible()
})
test('Queue mode trigger menu is visible', async ({ comfyPage }) => {
await expect(comfyPage.actionbar.queueButton.dropdownButton).toBeVisible()
})
test('Clicking queue mode trigger opens mode menu', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
})
test('Queue mode menu shows available modes', async ({ comfyPage }) => {
const options = await comfyPage.actionbar.queueButton.openOptions()
await expect(options.menu).toBeVisible()
await expect(options.modeItems).toHaveText(queueModeLabels)
})
test('Selecting a non-default mode updates the Run button label', async ({
comfyPage
}) => {
const queueButton = comfyPage.actionbar.queueButton
const options = await queueButton.openOptions()
await expect(options.menu).toBeVisible()
await options.selectMode(runOnChangeLabel)
await expect(queueButton.primaryButton).toContainText(runOnChangeLabel)
})
test('Run button sends prompt when clicked', async ({ comfyPage }) => {
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route('**/api/prompt', async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
})
await comfyPage.actionbar.queueButton.primaryButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
# v2 Extension API — Touch-Point Database
This directory is the **canonical compatibility-surface map** for the upcoming
v2 extension API redesign. Every API surface that real-world ComfyUI
extensions touch is enumerated here, weighted by usage frequency and ecosystem
star count, with citations to verifiable evidence (file paths and line
numbers in real custom-node repos).
It exists so the v2 redesign can answer two questions deterministically:
1. **What will silently break?** — every entry maps to a v2 replacement (or to
an explicit "deprecated, no replacement" decision).
2. **What does the v2 test framework need to cover?** — every entry maps to
≥1 test target so the test floor reflects real extension shapes.
## Artifacts
| File | Role |
|---|---|
| [`touch-points-plan.md`](./touch-points-plan.md) | Methodology, schema, surface-family enumeration, severity rubric |
| [`touch-points-database.yaml`](./touch-points-database.yaml) | Source of truth — 52 patterns × 15 surface families with evidence rows |
| [`touch-points-star-cache.yaml`](./touch-points-star-cache.yaml) | GitHub star/fork/last-commit snapshot for every cited repo (drift detection) |
| [`touch-points-rollup.yaml`](./touch-points-rollup.yaml) | Computed blast-radius scores per pattern (sorted) — the prioritization output |
| [`scripts/fetch-stars.sh`](./scripts/fetch-stars.sh) | Refresh the star cache via `gh api` |
| [`scripts/rollup-blast-radius.py`](./scripts/rollup-blast-radius.py) | Recompute blast radius from database + star cache |
| [`scripts/add-evidence.py`](./scripts/add-evidence.py) | Idempotently merge new evidence rows / new patterns into the database |
## The 15 surface families
| Family | One-liner |
|---|---|
| **S1** | `ComfyExtension` lifecycle hooks (`init`, `setup`, `nodeCreated`, `beforeRegisterNodeDef`, …) |
| **S2** | `LGraphNode.prototype` methods extensions monkey-patch (`onConnectionsChange`, `onSerialize`, `onDrawForeground`, …) |
| **S3** | `LGraphCanvas.prototype` methods extensions monkey-patch (`processKey`, `processContextMenu`, `drawNode`, …) |
| **S4** | Widget-level patterns — `.callback` chaining, `.value` r/w, `.serializeValue`, `.options.*`, DOM widgets |
| **S5** | `ComfyApi` / `app.api` event surfaces — execution lifecycle WebSocket events |
| **S6** | `ComfyApp` god-object touch points — `app.graphToPrompt`, `app.queuePrompt`, `app.api.fetchApi`, … |
| **S7** | Window / global escape hatches — `window.app`, `window.LiteGraph`, `globalThis.LGraphCanvas` |
| **S8** | Special node properties (magic flags) — `isVirtualNode`, `serialize_widgets`, `category`, `color_on` |
| **S9** | Non-Node entity kinds (per [ADR 0008](../decisions/0008-entity-taxonomy.md)) — subgraphs, groups, reroutes, links |
| **S10** | Dynamic node API — `addInput` / `removeInput` / `addOutput` / `removeOutput` slot mutation at runtime |
| **S11** | Graph-level state and change-tracking — `graph.add`, `graph.remove`, `graph.serialize`, version bumps |
| **S12** | Shell UI registries — `extensionManager.registerSidebarTab`, bottom panel, commands, toasts |
| **S13** | Schema interpretation — `ComfyNodeDef` / `InputSpec` consumers (validation, default values, type coercion) |
| **S14** | Identity / Locator scheme — node IDs, slot keys, widget identity across reload |
| **S15** | Output system — preview-image / preview-any / display-text axis (per `widget-api-thoughts.md`) |
Full details, schema, and severity rubric are in [`touch-points-plan.md`](./touch-points-plan.md).
## Top 12 patterns by blast radius
Computed from [`touch-points-rollup.yaml`](./touch-points-rollup.yaml). Blast
radius is `log10(1+stars)·1.0 + log10(1+occurrences)·0.7 +
(signature_count-1)·0.5 + silent_breakage·0.5 + lifecycle_coupling·0.4`.
| Rank | BR | ★ sum | occ | sig | Pattern | Surface |
|---:|---:|---:|---:|---:|---|---|
| 1 | 6.67 | 17 101 | 7 | 1 | `S6.A1` | `app.graphToPrompt` monkey-patching ⚠️ CRITICAL |
| 2 | 5.42 | 2 567 | 1 | 1 | `S9.SG1` | Subgraph "set/get virtual node" pattern (KJNodes-style) |
| 3 | 5.27 | 4 314 | 4 | 1 | `S11.G2` | `graph.add` / `graph.remove` / `graph.findNodesByType` / `graph.findNodeById` / `graph.serialize` / `graph.configure` |
| 4 | 5.23 | 1 808 | 3 | 1 | `S10.D1` | `node.addInput` / `node.removeInput` / `node.addOutput` / `node.removeOutput` dynamic slot mutation |
| 5 | 5.18 | 3 049 | 5 | 1 | `S2.N13` | `nodeType.prototype.onConnectOutput` patching |
| 6 | 5.08 | 6 147 | 4 | 1 | `S4.W2` | `node.addDOMWidget(name, type, element, options)` |
| 7 | 5.01 | 412 | 6 | 1 | `S2.N15` | `nodeType.prototype.serialize` / `node.serialize` direct method patching |
| 8 | 4.89 | 1 789 | 4 | 1 | `S2.N14` | `nodeType.prototype.onWidgetChanged` patching |
| 9 | 4.89 | 7 932 | 6 | 1 | `S2.N4` | `nodeType.prototype.onRemoved` patching (de-facto teardown) |
| 10 | 4.66 | 1 837 | 6 | 1 | `S4.W3` | `widget.serializeValue` direct assignment |
| 11 | 4.61 | 1 788 | 1 | 1 | `S2.N12` | `nodeType.prototype.onConnectInput` patching |
| 12 | 4.55 | 1 793 | 5 | 1 | `S6.A3` | `api.fetchApi` — extensions hit backend HTTP endpoints |
The top three pattern categories — graph mutation (`S11.G2`, `S10.D1`),
prototype patching (`S2.*`), and the `app.graphToPrompt` god-object — together
account for the majority of the blast radius and define the v2 API's
non-negotiable compatibility surfaces.
## Refresh workflow
The database is curated by hand; the star cache and rollup are derived.
```bash
# from this directory
bash scripts/fetch-stars.sh # refresh GitHub stars (needs `gh` auth)
python3 scripts/rollup-blast-radius.py # recompute touch-points-rollup.yaml
```
To add new evidence or new patterns discovered during a future MCP
code-search sweep, edit `scripts/add-evidence.py` (the inline `APPEND` and
`NEW_PATTERNS` blocks are the source of truth for reproducibility) and run:
```bash
python3 scripts/add-evidence.py
python3 scripts/rollup-blast-radius.py
```
## Source documents
The 52 patterns were derived from three primary inputs, then expanded by an
MCP code-search sweep across 87 ecosystem repos:
1. **`AGENTS.md` §5** in this repo — 40+ repo callouts for contributor
conventions and known extension surfaces.
2. **[ADR 0008 — Entity Taxonomy](../decisions/0008-entity-taxonomy.md)** —
defines the non-Node entity kinds (subgraphs, groups, reroutes, links)
that drive surface family **S9**.
3. **`widget-api-thoughts.md`** (in the cross-repo workspace) — the output
system axis and widget lifecycle dependencies that drive surface family
**S15** plus the lifecycle-coupling weight.
## Cross-references
This database is consumed by, and consumes, the rest of the ECS architecture
docs:
- [`../ecs-target-architecture.md`](../ecs-target-architecture.md) — the
target ECS shape this v2 API redesign serves
- [`../ecs-world-command-api.md`](../ecs-world-command-api.md) — the World /
Command API that v2 extensions will program against
- [`../ecs-migration-plan.md`](../ecs-migration-plan.md) — how we get from
today's monkey-patched LiteGraph to v2 + ECS
- [`../ecs-lifecycle-scenarios.md`](../ecs-lifecycle-scenarios.md) — the
lifecycle scenarios the test framework must cover (every touch-point row
here ⇒ ≥1 scenario there)
- [`../entity-interactions.md`](../entity-interactions.md) /
[`../entity-problems.md`](../entity-problems.md) — the entity-model
problems v2 must not perpetuate
- [`../change-tracker.md`](../change-tracker.md) — the change-tracking
contract that S11 (graph state) and S2 (`onSerialize`/`onDeserialize`
patches) must remain compatible with

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
# add-evidence-pass2.py — second MCP sweep. Appends evidence to under-evidenced
# patterns and adds new patterns discovered in pass-2 (graph batching seam,
# window.* globals, setDirtyCanvas redraw idiom).
#
# Idempotent: skips evidence already present (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence-pass2.py
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to append to existing patterns ──────────────────────────────
APPEND = {
"S2.N17": [ # onSelected / onDeselected
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider.js", 1, variant="prototype-patch", breakage_class="silent",
notes="mxToolkit Slider patches onSelected for highlight state"),
ev("nodelee733/ComfyUI-mxToolkit", "js/Slider2D.js", 1, variant="prototype-patch", breakage_class="silent"),
],
"S2.N19": [ # onResize
ev("SKBv0/ComfyUI_SKBundle", "js/MultiFloat.js", 1, variant="prototype-patch", breakage_class="silent",
notes="MultiFloat widget syncs internal layout on resize"),
ev("PGCRT/CRT-Nodes", "js/Magic_Lora_Loader.js", 1, variant="prototype-patch", breakage_class="silent"),
ev("dorpxam/ComfyUI-LTX2-Microscope", "web/js/ui/visualizer.js", 1, variant="prototype-patch", breakage_class="silent",
notes="visualizer reflows DOM widget on resize"),
],
"S9.R1": [ # Reroute manipulation
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="graph.reroutes.values()", breakage_class="loud",
notes="iterates reroute map directly — fork of frontend, but represents real internal contract surface"),
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="external doc treats graph.reroutes as part of subgraph contract"),
],
"S9.SG1": [ # Set/Get virtual node
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="virtual-node-companion", breakage_class="silent",
notes="Flux Continuum hint system depends on Set/Get virtual node graph"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1, variant="full-implementation",
breakage_class="loud", notes="another SetInput/GetOutput pack — variant of KJNodes pattern"),
],
"S13.SC1": [ # ComfyNodeDef inspection
ev("xeinherjer-dev/ComfyUI-XENodes", "web/js/combo_selector.js", 1, variant="nodeData.input.optional",
breakage_class="silent", notes="reads nodeData.input.optional to drive UI generation"),
ev("StableLlama/ComfyUI-basic_data_handling", "web/js/dynamicnode.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools", "js/sb_concat.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
ev("BennyKok/comfyui-deploy", "web-plugin/index.js", 1, variant="nodeData.input.required",
breakage_class="silent", notes="comfyui-deploy is widely used; treats schema as a public contract"),
ev("egormly/ComfyUI-EG_Tools", "web/dynamic_inputs.js", 1, variant="nodeData.input.optional",
breakage_class="silent"),
],
"S3.C1": [ # LGraphCanvas.prototype.* monkey-patching — drawNodeShape variant
ev("yolain/ComfyUI-Easy-Use-Frontend", "src/extensions/ui.js", 1, variant="drawNodeShape-patch",
breakage_class="silent", notes="Easy-Use is a major pack; patches LGraphCanvas.prototype.drawNodeShape"),
ev("melMass/comfy_mtb", "web/note_plus.js", 1, variant="canvas-draw-patch", breakage_class="silent",
notes="comfy_mtb (popular pack) — note_plus draws decorations via canvas patching"),
ev("lucafoscili/lf-nodes", "web/src/nodes/reroute.ts", 1, variant="onDrawForeground+canvas-draw",
breakage_class="silent"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 1, variant="onDrawForeground",
breakage_class="silent"),
],
"S10.D2": [ # disconnectInput / disconnectOutput / connect
ev("MockbaTheBorg/ComfyUI-Mockba", "js/slider.js", 1, variant="programmatic-disconnect",
breakage_class="loud", notes="app.graph.getNodeById(tlink.target_id).disconnectInput(tlink.target_slot)"),
ev("vjumpkung/comfyui-infinitetalk-native-sampler", "README.md", [1, 50], variant="documented-as-API",
breakage_class="loud", notes="3rd-party docs treat node.disconnect* as a stable extension surface"),
],
"S8.P1": [ # isVirtualNode = true
ev("ComfyNodePRs/PR-comfyui-pkg39-ccab78b5", "js/libs/image.js", [541, 1382], variant="filter-by-virtual",
breakage_class="loud", notes="extension code filters nodes by isVirtualNode — treats it as discovery API"),
],
}
# ─── Brand-new patterns discovered in pass-2 ──────────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S11.G3",
"surface_family": "S11",
"surface": "graph.beforeChange / graph.afterChange — explicit batching seam for multi-step mutations",
"fingerprint": "graph.beforeChange(); ...mutations...; graph.afterChange();",
"semantic": (
"extensions wrap multi-node/multi-link mutations in beforeChange/afterChange so undo, "
"dirty-tracking, and re-render coalesce around the batch instead of per-mutation"
),
"v2_replacement": "world.batch(() => { ...mutations... }) — typed batching API",
"decision_ref": (
"First-class batching is required for any reactive layer that wants stable diffs; "
"v2 should expose this as a mandatory wrapper for multi-mutation operations"
),
"test_target": "GRAPH_BATCH_BOUNDARY",
"lifecycle_coupling": 1,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("nodetool-ai/nodetool", "subgraphs.md", [1, 50], variant="documented-pattern", breakage_class="loud",
notes="docs use beforeChange/afterChange around subgraph promotion"),
ev("linjm8780860/ljm_comfyui", "src/utils/vintageClipboard.ts", 1, variant="paste-undo-batch",
breakage_class="loud", notes="paste flow batches mutations across clipboard restore"),
],
},
{
"pattern_id": "S7.G1",
"surface_family": "S7",
"surface": "window.LiteGraph / window.comfyAPI.* — globals as public surface",
"fingerprint": "window.LiteGraph.createNode(...); window.comfyAPI.app.app",
"semantic": (
"extensions reach into the global namespace for LiteGraph constructors/enums or for the "
"module-as-global comfyAPI registry. This is the closest thing to a 'public ABI' today"
),
"v2_replacement": (
"explicit `import { app, graph, LiteGraph } from '@comfy/extension'` + a typed registry "
"keyed by extension name; window.* should remain as a deprecated read-only mirror"
),
"decision_ref": (
"Cannot break window.LiteGraph immediately — too much ecosystem code reaches for it. "
"Must ship typed import path first, then deprecate. Similar story to S11.G2 graph globals."
),
"test_target": "GLOBAL_NAMESPACE_COMPAT",
"lifecycle_coupling": 0,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/hint.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("SpaceWarpStudio/ComfyUI-SetInputGetOutput", "web/js/setinputgetoutput.js", 1,
variant="window.LiteGraph", breakage_class="loud"),
ev("ArtHommage/HommageTools", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("PROJECTMAD/PROJECT-MAD-NODES", "web/js/index.js", 1, variant="window.LiteGraph", breakage_class="loud"),
ev("ryanontheinside/ComfyUI_RyanOnTheInside", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
ev("stavzszn/comfyui-teskors-utils", "web/js/index.js", 1, variant="window.LiteGraph",
breakage_class="loud"),
],
},
{
"pattern_id": "S11.G4",
"surface_family": "S11",
"surface": "graph.setDirtyCanvas(true, true) — imperative canvas-redraw trigger",
"fingerprint": "node.graph?.setDirtyCanvas?.(true, true); app.graph.setDirtyCanvas(true, true);",
"semantic": (
"after any imperative mutation extensions call setDirtyCanvas to force a redraw — the "
"ecosystem's de-facto 'reactivity flush' primitive. v2 reactivity should make this unnecessary"
),
"v2_replacement": (
"implicit — reactive system schedules redraw automatically when tracked entity mutates. "
"Provide an escape hatch `world.markDirty()` only for non-reactive third-party canvas use"
),
"decision_ref": (
"Replacing this surface is the strongest evidence that v2 reactivity actually buys something. "
"Should be in v2 'value proposition' demo extension"
),
"test_target": "REDRAW_NO_LONGER_NEEDED",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 111,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 285,
variant="post-mutation-redraw", breakage_class="silent"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/ui/module_node_picker_node_factory.js", 189,
variant="post-mutation-redraw", breakage_class="silent"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", [776, 1087],
variant="post-mutation-redraw", breakage_class="silent",
notes="multiple call sites — extension assumes manual flush is the contract"),
],
},
{
"pattern_id": "S10.D3",
"surface_family": "S10",
"surface": "node.setSize(node.computeSize()) — imperative resize after dynamic mutation",
"fingerprint": "node.setSize?.(node.computeSize())",
"semantic": (
"after dynamic widget/input/output mutation, extensions manually call computeSize+setSize "
"to reflow the node. Companion to S2.N11 (computeSize override) and S11.G4 (setDirtyCanvas)"
),
"v2_replacement": (
"automatic — reactive layout system recomputes node size when widget/slot collection changes. "
"Expose `nodeHandle.requestLayout()` only as escape hatch"
),
"decision_ref": "Pairs with S11.G4 — both are 'manual flush' idioms that v2 should obviate",
"test_target": "AUTO_RELAYOUT_ON_MUTATION",
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/widget_visibility_profiles.js", 283,
variant="setSize+computeSize", breakage_class="silent",
notes="exact 'node.setSize?.(node.computeSize())' canonical idiom"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_node.js", 466,
variant="manual-height", breakage_class="silent",
notes="commented-out manual setSize — shows the pattern is well-known"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
existing = {normalize_evidence_key(e) for e in p["evidence"]}
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-2" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-2")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# add-evidence.py — append evidence to existing patterns and add NEW patterns
# discovered during the MCP sweep. Idempotent: skips evidence already present
# (matched by repo+file+lines).
#
# Run: python3 scripts/add-evidence.py
#
# Source-of-truth for evidence is inline below — keeping it in version
# control makes the sweep reproducible and reviewable.
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
def url(repo: str, file: str, line: int) -> str:
return f"https://github.com/{repo}/blob/main/{file}#L{line}"
def ev(repo, file, lines, **kw):
e = {
"repo": repo,
"file": file,
"lines": lines if isinstance(lines, list) else [lines],
"url": url(repo, file, lines if isinstance(lines, int) else lines[0]),
}
e.update(kw)
return e
# ─── Evidence to merge into existing patterns ─────────────────────────────
APPEND = {
"S2.N12": [
# already has core dynamicWidgets entry
],
"S2.N13": [
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_relay.js", [90, 92], variant="subclass-override", breakage_class="loud", notes="rgthree — major pack. Subclass override pattern (calls super)."),
ev("rgthree/rgthree-comfy", "web/comfyui/node_mode_repeater.js", [21, 24], variant="subclass-override", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_relay.ts", [146, 153], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "src_web/comfyui/node_mode_repeater.ts", [46, 56], variant="subclass-override-ts", breakage_class="loud"),
ev("rgthree/rgthree-comfy", "web/comfyui/base_any_input_connected_node.js", [136, 138], variant="subclass-override", breakage_class="loud"),
],
"S2.N14": [
ev("niknah/presentation-ComfyUI", "js/PresentationDropDown.js", [12, 75], variant="prototype-chain", breakage_class="silent", notes="captures original onWidgetChanged via prototype chain"),
ev("chyer/Chye-ComfyUI-Toolset", "web/comfyui/text_file_loader.js", [35, 115], variant="instance-method", breakage_class="silent"),
],
"S2.N15": [
ev("Azornes/Comfyui-LayerForge", "js/CanvasView.js", 1438, variant="prototype-replace", breakage_class="silent", notes="LayerForge (313★) — replaces serialize wholesale"),
ev("Azornes/Comfyui-LayerForge", "src/CanvasView.ts", 1657, variant="prototype-replace-ts", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_wan_motion_presets.js", 598, variant="prototype-replace", breakage_class="silent"),
ev("IAMCCS/IAMCCS-nodes", "web/iamccs_ltx2_extension_presets.js", 350, variant="prototype-replace", breakage_class="silent"),
ev("DazzleNodes/ComfyUI-Smart-Resolution-Calc", "web/utils/serialization.js", 32, variant="prototype-replace", breakage_class="silent"),
ev("alankent/ComfyUI-OA-360-Clip", "web/oa_360_clip.js", 900, variant="prototype-replace", breakage_class="silent"),
],
"S2.N16": [
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 328, variant="push", breakage_class="silent", notes="extension pushes to node.widgets directly"),
ev("max-dingsda/ComfyUI-AllinOne-LazyNode", "web/js/aio_core_preview.js", 170, variant="push", breakage_class="silent"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-set-get.js", 9, variant="indexed-read", breakage_class="loud", notes="reads node.widgets[0].value to get name"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-load-image.js", 56, variant="indexOf", breakage_class="loud"),
ev("viswamohankomati/ComfyUI-Copilot", "ComfyUI/custom_nodes/ComfyUI-Copilot/ui/src/utils/comfyuiWorkflowApi2Ui.ts", [305, 316], variant="widgets_values-push", breakage_class="silent", notes="touches node.widgets_values, the serialized array"),
],
"S11.G1": [
ev("FloyoAI/ComfyUI-SoundFlow", "js/PreviewAudio.js", 293, variant="post-mutation-bump", breakage_class="silent", notes="bumps version after node-internal mutation to trigger redraw"),
ev("krismasdev/ComfyUI-Flux-Continuum", "web/outputgetnode.js", 84, variant="post-mutation-bump", breakage_class="silent"),
ev("coeuskoalemoss/comfyUI-layerstyle-custom", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent"),
ev("40740/ComfyUI_LayerStyle_Bmss", "js/dz_mtb_widgets.js", 292, variant="post-mutation-bump", breakage_class="silent", notes="duplicate-of-coeuskoalemoss pattern — fork"),
],
"S11.G2": [
ev("yolain/ComfyUI-Easy-Use", "web_version/v1/js/easy/easyExtraMenu.js", 439, variant="add+createNode", breakage_class="loud", notes="Easy-Use is a major pack; uses graph.add(LiteGraph.createNode(...))"),
ev("KumihoIO/kumiho-plugins", "comfyui/web/js/kumiho.js", 431, variant="add+createNode", breakage_class="loud"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-ui-enhancements.js", 29, variant="remove-then-add", breakage_class="loud", notes="swap nodes by remove+add — preserves layout via savedProps"),
ev("Comfy-Org/ComfyUI_frontend", "browser_tests/tests/workflowPersistence.spec.ts", [351, 413], variant="add+createNode", breakage_class="loud", notes="OUR OWN E2E TESTS rely on window.app.graph.add(window.LiteGraph.createNode(...))"),
],
"S12.UI1": [
ev("robertvoy/ComfyUI-Distributed", "web/main.js", [269, 270], variant="extensionManager.registerSidebarTab", breakage_class="loud", notes="real call site for sidebar registration"),
ev("criskb/Comfypencil", "web/comfy_pencil_extension.js", [955, 956], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
ev("maxi45274/ComfyUI_LinkFX", "js/LinkFX.js", [707, 709], variant="extensionManager.registerSidebarTab", breakage_class="loud"),
],
"S10.D1": [
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/node.js", [18, 53], variant="dynamic-addInput-loop", breakage_class="loud", notes="real-world dynamic input expansion: this.addInput('infix '+i,'STRING')"),
ev("r-vage/ComfyUI_Eclipse", "js/eclipse-mode-nodes.js", [42, 106], variant="virtual-node-setup", breakage_class="loud", notes="Eclipse uses addOutput within isVirtualNode setup"),
ev("Comfy-Org/ComfyUI_frontend", "src/lib/litegraph/src/canvas/LinkConnector.core.test.ts", [121, 158], variant="OUR-TESTS", breakage_class="loud", notes="OUR OWN TESTS depend on addOutput"),
],
"S9.S1": [
ev("lordwedggie/xcpNodes", "js/xcpDerpINT.js", 162, variant="output-color_on-assignment", breakage_class="silent", notes="this.outputs[0].color_on = templateSlotColorOn — direct slot visual override"),
ev("nodetool-ai/nodetool", "subgraphs.md", [267, 299], variant="documented-pattern", breakage_class="loud", notes="external docs reference color_on for subgraph slot inheritance"),
],
"S4.W4": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", [24, 27], variant="includes-then-push", breakage_class="silent", notes="checks values then mutates"),
ev("zzggi2024/shaobkj", "js/dynamic_inputs.js", [374, 376], variant="snapshot-then-mutate", breakage_class="silent", notes="saves __originalValues snapshot before mutating widget.options.values"),
ev("EnragedAntelope/EA_LMStudio", "web/ea_lmstudio.js", 11, variant="documented-fallback", breakage_class="loud", notes="explicit comment: 'Legacy LiteGraph frontend: full support via widget.options.values'"),
],
}
# ─── Brand-new patterns discovered during sweep ───────────────────────────
NEW_PATTERNS = [
{
"pattern_id": "S6.A3",
"surface_family": "S6",
"surface": "api.fetchApi — extensions hit backend HTTP endpoints",
"fingerprint": "await api.fetchApi('/upload/image', { method: 'POST', body: data })",
"semantic": "extensions call ComfyAPI.fetchApi as the canonical way to reach backend HTTP routes (auth, base URL, error handling all handled)",
"v2_replacement": "ctx.api.fetch(path, init) typed wrapper; same semantics, narrower surface",
"decision_ref": "Pattern is widely used and CORRECT — keep contract, just type it",
"test_target": "BACKEND_HTTP_CLIENT",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/video_cut_match_upload.js", 54, variant="POST-multipart", breakage_class="loud"),
ev("AlexZ1967/ComfyUI_ALEXZ_tools", "web/api/module_node_picker_api.js", 43, variant="generic-wrapper", breakage_class="loud"),
ev("akawana/ComfyUI-Folded-Prompts", "js/FPFoldedPrompts.js", 1227, variant="POST-upload", breakage_class="loud"),
ev("zhupeter010903/ComfyUI-XYZ-prompt-library", "js/prompt_library_window.js", 1379, variant="GET", breakage_class="loud"),
ev("Comfy-Org/ComfyUI_frontend", "src/components/common/BackgroundImageUpload.vue", 61, variant="POST-upload", breakage_class="loud", notes="OUR OWN UI uses api.fetchApi for image upload"),
],
},
{
"pattern_id": "S6.A4",
"surface_family": "S6",
"surface": "app.queuePrompt / app.api.queuePrompt patching or direct call",
"fingerprint": "const orig = window.app.api.queuePrompt; window.app.api.queuePrompt = async function(...args) {...; return orig(...args)}",
"semantic": "intercept or trigger workflow execution; auth tokens, custom payload mutation, sidebar 'Run' buttons",
"v2_replacement": "graph.run({ batch }) explicit API + app.on('beforeRun', payload => mutate(payload))",
"decision_ref": "Pairs with S6.A1 graphToPrompt as the OTHER half of the execute-pipeline interception story",
"test_target": "PROMPT_QUEUE_INTERCEPT",
"lifecycle_coupling": 2,
"severity": "CRITICAL",
"evidence_status": "swept",
"evidence": [
ev("gigici/ComfyUI_BlendPack", "js/ui/NodeUI.js", 99, variant="bind-then-replace", breakage_class="silent", notes="window.app.api.queuePrompt?.bind(window.app.api) — patches the API-level queue"),
ev("MajoorWaldi/ComfyUI-Majoor-AssetsManager", "js/features/viewer/workflowSidebar/sidebarRunButton.js", [317, 321], variant="multi-path-fallback", breakage_class="loud", notes="documents 4 distinct invocation paths: app.api.queuePrompt, app.queuePrompt, fetch /prompt, etc."),
ev("rohapa/comfyui-replay", "README.md", [497, 975], variant="call+fallback", breakage_class="loud", notes="app.queuePrompt(0,1) with raw fetch /prompt fallback"),
],
},
{
"pattern_id": "S5.A3",
"surface_family": "S5",
"surface": "api.addEventListener('execution_start' | 'execution_success' | 'execution_error' | 'execution_cached' | 'executing' | 'status' | 'reconnecting')",
"fingerprint": "api.addEventListener('execution_start', e => ...)",
"semantic": "extensions subscribe to backend execution lifecycle WebSocket events",
"v2_replacement": "ctx.execution.on('start' | 'success' | 'error' | 'cached', payload => ...) typed events",
"decision_ref": "Cross-references S5.A1 (existence-proof of events-everywhere)",
"test_target": "EXECUTION_LIFECYCLE_EVENTS",
"lifecycle_coupling": 0,
"severity": "HIGH",
"evidence_status": "swept",
"evidence": [
ev("zzw5516/ComfyUI-zw-tools", "entry/entry.js", [27, 28], variant="execution_start", breakage_class="loud"),
ev("flymyd/koishi-plugin-comfyui-client", "src/ComfyUINode.ts", 109, variant="execution_start-case", breakage_class="loud"),
ev("kyuz0/amd-strix-halo-comfyui-toolboxes", "scripts/benchmark_workflows.py", 52, variant="execution_start-message-type", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "README.md", [144, 179], variant="documented-event-list", breakage_class="loud"),
ev("philippjbauer/devint25-comfyui-api-demo", "Models/ComfyModels.cs", 159, variant="enum-of-event-names", breakage_class="loud", notes="C# wrapper enumerates the WebSocket event vocabulary as the public API"),
ev("huafitwjb/ComfyUI-GO-Mobile-app", "app/src/main/java/com/example/myapplication/util/Constants.kt", 26, variant="execution_success-const", breakage_class="loud"),
ev("hernantech/comfymcp", "src/comfymcp/client/types.py", 17, variant="execution_success-enum", breakage_class="loud"),
ev("choovin/comfyui-api", "README.md", [57, 1945], variant="execution_success-doc", breakage_class="loud", notes="explicit 'Sidecar-like tracing' depending on execution_* events as public API"),
],
},
]
def normalize_evidence_key(e):
return (e.get("repo"), e.get("file"), tuple(e.get("lines") or []))
def main():
db = yaml.safe_load(DB.read_text())
appended = 0
skipped = 0
for pid, new_evs in APPEND.items():
for p in db["patterns"]:
if p["pattern_id"] == pid:
existing = {normalize_evidence_key(e) for e in (p.get("evidence") or [])}
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
for e in new_evs:
if normalize_evidence_key(e) in existing:
skipped += 1
continue
p["evidence"].append(e)
appended += 1
# Mark evidence_status as swept now that we've sourced real data
p["evidence_status"] = "swept"
break
else:
print(f"⚠️ pattern {pid} not found")
added_new = 0
existing_ids = {p["pattern_id"] for p in db["patterns"]}
for np in NEW_PATTERNS:
if np["pattern_id"] in existing_ids:
print(f"⚠️ pattern {np['pattern_id']} already exists — skipping")
continue
db["patterns"].append(np)
added_new += 1
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
if "evidence-sweep-pass-1" not in db["meta"].get("sweeps_done", []):
db["meta"]["sweeps_done"].append("evidence-sweep-pass-1")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
print(f"✅ appended {appended} evidence rows ({skipped} dupes skipped)")
print(f"✅ added {added_new} new patterns")
print(f"✅ DB now has {len(db['patterns'])} patterns")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# fetch-stars.sh — populate research/touch-points/star-cache.yaml
# Reads database.yaml, extracts unique repo: entries, queries gh api for stars.
# Usage: bash scripts/fetch-stars.sh
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
DB="$DIR/../touch-points-database.yaml"
CACHE="$DIR/../touch-points-star-cache.yaml"
if ! command -v gh >/dev/null 2>&1; then
echo "❌ gh CLI not installed"
exit 1
fi
# Extract unique repo: entries from database
repos=$(grep -E '^\s*-\s*repo:\s' "$DB" | sed -E 's/^\s*-\s*repo:\s*//' | sort -u | grep -v '^$' || true)
today=$(date +%Y-%m-%d)
{
echo "# ───────────────────────────────────────────────────────────────────────"
echo "# GitHub star cache for repos referenced in database.yaml"
echo "# Refresh: bash scripts/fetch-stars.sh"
echo "# Asof dates allow drift detection"
echo "# ───────────────────────────────────────────────────────────────────────"
echo ""
echo "asof: $today"
echo "populated_via: scripts/fetch-stars.sh"
echo ""
echo "repos:"
} > "$CACHE.tmp"
count=0
err_count=0
for r in $repos; do
count=$((count + 1))
printf " [%3d] %s ... " "$count" "$r" >&2
if data=$(gh api "repos/$r" 2>/dev/null); then
stars=$(echo "$data" | jq -r '.stargazers_count')
archived=$(echo "$data" | jq -r '.archived')
forks=$(echo "$data" | jq -r '.forks_count')
last=$(echo "$data" | jq -r '.pushed_at' | cut -dT -f1)
echo "$stars" >&2
{
echo " - repo: $r"
echo " stars: $stars"
echo " archived: $archived"
echo " forks: $forks"
echo " last_commit: $last"
echo " asof: $today"
} >> "$CACHE.tmp"
else
err_count=$((err_count + 1))
echo "ERROR" >&2
{
echo " - repo: $r"
echo " stars: null"
echo " error: \"gh api failed (rate limit / repo missing / network)\""
echo " asof: $today"
} >> "$CACHE.tmp"
fi
done
mv "$CACHE.tmp" "$CACHE"
echo "" >&2
echo "✅ Wrote $CACHE$count repos, $err_count errors" >&2

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
# merge-staging-pass3.py — single-threaded merger for pass-3 staging files.
#
# Reads:
# research/touch-points/staging/r8-evidence.yaml (clone-grep)
# research/touch-points/staging/r9-security.yaml (security scan + proposed S16.* patterns)
# research/touch-points/staging/r9-guides.yaml (sanctioned surfaces from docs we ship)
# research/touch-points/staging/r9-cookiecutter.yaml (scaffolded = forced-public surfaces)
#
# Writes back to:
# research/touch-points/database.yaml
#
# Safe to re-run; per-(repo, file, lines) dedup is enforced.
# R8 evidence is capped at 6 rows per pattern (already capped per repo+pattern in producer).
#
# R9.popularity is metadata about repos, not evidence — skipped here.
# R9.qa is regression-scenario seeds for I-TF.3 — referenced but not merged into DB.
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "research" / "touch-points" / "database.yaml"
STAGING = ROOT / "research" / "touch-points" / "staging"
R8 = STAGING / "r8-evidence.yaml"
R9_SEC = STAGING / "r9-security.yaml"
R9_GUIDES = STAGING / "r9-guides.yaml"
R9_CK = STAGING / "r9-cookiecutter.yaml"
CAP_PER_PATTERN_FROM_R8 = 8 # adjust if DB explodes
def normalize_lines(lines):
if isinstance(lines, str):
# R8 emitted strings like "[119, 131]" — convert
try:
return tuple(eval(lines, {"__builtins__": {}}, {}))
except Exception:
return (lines,)
if isinstance(lines, list):
return tuple(lines)
return (lines,)
def evkey(e):
return (e.get("repo"), e.get("file"), normalize_lines(e.get("lines")))
def append_dedup(target_evidence, new_rows, cap=None):
existing = {evkey(e) for e in target_evidence}
appended = 0
skipped = 0
rows_to_consider = list(new_rows)
if cap and len(rows_to_consider) > cap:
# Prefer rows from higher-star repos when capping.
# Order is producer-defined; keep first `cap`.
rows_to_consider = rows_to_consider[:cap]
for e in rows_to_consider:
# Normalize line representation
if isinstance(e.get("lines"), str):
e["lines"] = list(normalize_lines(e["lines"]))
if evkey(e) in existing:
skipped += 1
continue
target_evidence.append(e)
existing.add(evkey(e))
appended += 1
return appended, skipped
def main():
db = yaml.safe_load(DB.read_text())
patterns_by_id = {p["pattern_id"]: p for p in db["patterns"]}
total_appended = 0
total_skipped = 0
new_patterns_added = 0
# ─── R8 (clone-grep) ────────────────────────────────────────────
r8 = yaml.safe_load(R8.read_text())
print(f"R8: {sum(len(v) for v in r8.values())} total rows across {len(r8)} patterns")
for pid, rows in r8.items():
if pid not in patterns_by_id:
print(f" ⚠️ R8 pattern {pid} not in DB — skipping")
continue
p = patterns_by_id[pid]
if "evidence" not in p or p["evidence"] is None:
p["evidence"] = []
a, s = append_dedup(p["evidence"], rows, cap=CAP_PER_PATTERN_FROM_R8)
total_appended += a
total_skipped += s
p["evidence_status"] = "swept"
# ─── R9.security: proposed S16.* patterns ───────────────────────
sec = yaml.safe_load(R9_SEC.read_text())
for sp in sec.get("proposed_patterns", []):
pid = sp.get("proposed_pattern_id")
if not pid:
continue
if pid in patterns_by_id:
print(f" R9.sec pattern {pid} already exists — appending evidence only")
target = patterns_by_id[pid]
else:
# Materialize the new pattern
new_p = {
"pattern_id": pid,
"surface_family": sp.get("surface_family", "S16"),
"surface": sp.get("surface", ""),
"fingerprint": sp.get("fingerprint", ""),
"semantic": sp.get("semantic", ""),
"v2_replacement": sp.get("v2_replacement", ""),
"decision_ref": sp.get("rationale", ""),
"test_target": sp.get("test_target", ""),
"lifecycle_coupling": 0,
"severity": "MEDIUM",
"evidence_status": "swept",
"evidence": [],
}
db["patterns"].append(new_p)
patterns_by_id[pid] = new_p
target = new_p
new_patterns_added += 1
print(f" R9.sec NEW pattern {pid}: {sp.get('surface', '')[:60]}")
# Materialize evidence rows from R9.sec
evidence_field = sp.get("evidence")
if isinstance(evidence_field, str):
try:
evidence_field = eval(evidence_field, {"__builtins__": {}}, {})
except Exception:
evidence_field = []
if not isinstance(evidence_field, list):
evidence_field = []
rows = []
for e in evidence_field:
if not isinstance(e, dict):
continue
rows.append({
"pattern_id": pid,
"repo": e.get("repo", "unknown"),
"file": e.get("file", "unknown"),
"lines": e.get("lines", [1]) if isinstance(e.get("lines"), (list, int)) else [1],
"url": e.get("url", ""),
"rule": e.get("rule", ""),
"source": "security",
"variant": e.get("rule", "yara/bandit-hit"),
})
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.cookiecutter: scaffolded surfaces ───────────────────────
ck = yaml.safe_load(R9_CK.read_text())
for entry in ck.get("scaffold_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "cookiecutter-comfy-extension",
"file": entry.get("template_file", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "cookiecutter",
"variant": "scaffolded-by-default",
"excerpt": entry.get("excerpt", ""),
"notes": "FORCED-PUBLIC: this surface is generated by the default scaffold, so v2 cannot break it without breaking new-extension onboarding",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── R9.guides: surfaces we teach in docs ───────────────────────
guides = yaml.safe_load(R9_GUIDES.read_text())
for entry in guides.get("sanctioned_surfaces", []):
pid = entry.get("pattern_id")
if not pid or pid not in patterns_by_id:
continue
target = patterns_by_id[pid]
if "evidence" not in target or target["evidence"] is None:
target["evidence"] = []
rows = [{
"pattern_id": pid,
"repo": "comfyanonymous/custom-nodes-guides",
"file": entry.get("taught_in", "unknown"),
"lines": entry.get("lines", [1]),
"url": "",
"source": "guides",
"variant": "taught-in-official-docs",
"excerpt": entry.get("excerpt", ""),
"notes": "SANCTIONED-PUBLIC: this surface is taught in official docs we ship, so v2 must keep it stable",
}]
a, s = append_dedup(target["evidence"], rows)
total_appended += a
total_skipped += s
# ─── Update meta ────────────────────────────────────────────────
db["meta"]["patterns_count"] = len(db["patterns"])
db["meta"]["sweep_status"] = "in-progress"
sweeps = db["meta"].setdefault("sweeps_done", [])
if "evidence-sweep-pass-3" not in sweeps:
sweeps.append("evidence-sweep-pass-3")
DB.write_text(yaml.safe_dump(db, sort_keys=False, width=200, allow_unicode=True))
total_evidence = sum(len(p.get("evidence") or []) for p in db["patterns"])
print()
print(f"✅ appended {total_appended} rows ({total_skipped} dupes skipped)")
print(f"✅ added {new_patterns_added} new patterns")
print(f"✅ DB now: {len(db['patterns'])} patterns, {total_evidence} evidence rows")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# rollup-blast-radius.py — compute per-pattern blast-radius metrics from
# database.yaml + star-cache.yaml, write to research/touch-points/rollup.yaml.
#
# Blast-radius formula (per PLAN.md):
# br = (log10(1 + cumulative_stars)) * w_stars (default 1.0)
# + (log10(1 + occurrence_count)) * w_occ (default 0.7)
# + (signature_count - 1) * w_sig (default 0.5)
# + silent_breakage_weight * w_silent (default 0.5)
# + lifecycle_coupling_weight * w_lifecycle (default 0.4)
#
# silent_breakage_weight & lifecycle_coupling_weight come from the per-pattern
# heuristics field; if absent they default to 0.
import math
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "touch-points-database.yaml"
STARS = ROOT / "touch-points-star-cache.yaml"
OUT = ROOT / "touch-points-rollup.yaml"
W = {
"stars": 1.0,
"occ": 0.7,
"sig": 0.5,
"silent": 0.5,
"lifecycle": 0.4,
}
def load_stars() -> dict[str, int]:
if not STARS.exists():
return {}
cache = yaml.safe_load(STARS.read_text())
out = {}
for r in cache.get("repos", []) or []:
if r.get("stars") is not None:
out[r["repo"]] = int(r["stars"])
return out
def main() -> int:
db = yaml.safe_load(DB.read_text())
stars = load_stars()
rows = []
for p in db.get("patterns", []) or []:
evidence = p.get("evidence") or []
repos = []
for e in evidence:
r = e.get("repo")
if r:
repos.append(r)
unique_repos = sorted(set(repos))
cum_stars = sum(stars.get(r, 0) for r in unique_repos)
occ = len(evidence)
sig_count = p.get("signature_count") or len(p.get("signatures") or []) or 1
# Pattern fields can be top-level or under 'heuristics'
h = p.get("heuristics") or {}
sev_map = {"CRITICAL": 2, "HIGH": 1.5, "MEDIUM": 1, "LOW": 0.5}
silent_w = float(h.get("silent_breakage", sev_map.get(p.get("severity", ""), 0)))
life_w = float(h.get("lifecycle_coupling", p.get("lifecycle_coupling", 0)))
br = (
math.log10(1 + cum_stars) * W["stars"]
+ math.log10(1 + occ) * W["occ"]
+ max(0, sig_count - 1) * W["sig"]
+ silent_w * W["silent"]
+ life_w * W["lifecycle"]
)
rows.append(
{
"pattern_id": p["pattern_id"],
"surface_family": p.get("surface_family"),
"name": p.get("name") or p.get("surface") or p.get("semantic_intent") or p.get("semantic"),
"occurrences": occ,
"unique_repos": len(unique_repos),
"cumulative_stars": cum_stars,
"signature_count": sig_count,
"silent_breakage": silent_w,
"lifecycle_coupling": life_w,
"blast_radius": round(br, 3),
"top_repos": [
{"repo": r, "stars": stars.get(r, 0)}
for r in sorted(unique_repos, key=lambda x: -stars.get(x, 0))[:5]
],
}
)
rows.sort(key=lambda r: -r["blast_radius"])
out = {
"meta": {
"generated_from": ["database.yaml", "star-cache.yaml"],
"weights": W,
"patterns_count": len(rows),
},
"patterns": rows,
}
OUT.write_text(yaml.safe_dump(out, sort_keys=False, width=120))
print(f"✅ wrote {OUT.relative_to(ROOT)} ({len(rows)} patterns)")
print()
print("Top 12 by blast radius:")
print(f" {'rank':>4} {'br':>6} {'★sum':>6} {'occ':>3} {'sig':>3} pattern")
for i, r in enumerate(rows[:12], 1):
print(
f" {i:>4} {r['blast_radius']:>6.2f} {r['cumulative_stars']:>6} "
f"{r['occurrences']:>3} {r['signature_count']:>3} {r['pattern_id']} {r['name']}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,313 @@
---
source: in-house (no external URL — synthesized from R4 + database.yaml + meeting transcript)
date_accessed: 2026-05-06
created: 2026-05-06
purpose: Plan + schema for the canonical touch-point database
status: active
---
# Touch-Point Database — Plan
## Why we are building this
The v2 extension API redesign (P1, D3.x) and the eventual test framework need a **shared evidence layer**: every API surface that real-world extensions touch, frequency-weighted by usage, with citations to verify. Without this, two failure modes are guaranteed:
1. **Silent regressions in v2.** Surfaces we don't know about can't be re-implemented or formally deprecated. The v2 service ships, big custom-node packs break, ComfyUI looks unstable.
2. **Test framework with the wrong floor.** Tests that don't reflect real extension shapes will pass v2 while production extensions break.
The database is the input for:
- v2 API gap analysis (D4 G1G13, plus future Gs surfaced here)
- Test framework design (widget-api-thoughts.md "Test Framework" section): every entry maps to ≥1 test case
- Migration guide writing (P3, DEP3, DEP4)
- "What can we actually delete" decisions (e.g., R4 found `loadedGraphNode` has 1 real call site)
## What the v2 POC shipped (CONTEXT for the audit)
There are 5 untracked v2 files in `ComfyUI_frontend` worktree (proof-of-concept):
- `src/types/extensionV2.ts``NodeHandle`, `WidgetHandle`, `defineNodeExtension`, `defineWidgetExtension` interfaces
- `src/services/extensionV2Service.ts` — scope registry, reactive mount system, handle factories (with inline open-question comments)
- `src/extensions/core/dynamicPrompts.v2.ts` — POC migration
- `src/extensions/core/imageCrop.v2.ts` — POC migration (13→12 lines)
- `src/extensions/core/previewAny.v2.ts` — POC migration (90→35 lines)
**Open questions left in v2 service comments** (touch-points must answer these):
- `setLabel` — special vs just an option? `setHidden` — same?
- `on('change')` watches `WidgetValue.value` only — how do extensions watch options/props?
- `setSerializeValue` callback — should be `on('serialize')` or `onBeforeSerialize`?
- Get/set vs getters/setters — should NodeHandle expose `get pos()` accessors?
- `getProperties` — current `properties` bag is heavily used by extensions for "persist across teardown"; v2 must verify that pattern still works
- `addWidget` returns by what mechanism? sync dispatch? promise?
- Widget figler tree / coverage report of "strangler-figged vs re-implemented vs unsupported"
These open questions become *test cases*: for each, the database tells us how many extensions in the wild touch the underlying surface.
## Comprehensive surface enumeration
The audit covers **8 surface families**. Each family contains specific patterns to search for.
### S1 — `ComfyExtension` lifecycle hooks (17 hooks)
From `src/types/comfy.ts`, lines 144-266:
| Hook | Core extension files using it | Replacement direction |
|---|---:|---|
| `init` | 16 | unchanged in v2 (ExtensionOptions.init) |
| `setup` | 3 | unchanged in v2 (ExtensionOptions.setup) |
| `addCustomNodeDefs` | 1 | unknown — may need v2 registration API |
| `getCustomWidgets` | 4 | replaced by `defineWidgetExtension` |
| `beforeRegisterNodeDef` | 10 | replaced by `nodeTypes` filter + `inspectNodeDef` (G1) |
| `beforeRegisterVueAppNodeDefs` | 0 | candidate for removal |
| `registerCustomNodes` | 3 | NO v2 equivalent (D4-G2 BLOCKER) |
| `loadedGraphNode` | 0 (core), 1 (entire wild corpus) | candidate for removal |
| `nodeCreated` | 12 | `defineNodeExtension({ nodeCreated })` |
| `beforeConfigureGraph` | 1 | needs decision — graph lifecycle hook |
| `afterConfigureGraph` | 0 | candidate for removal |
| `getSelectionToolboxCommands` | 0 | candidate for removal |
| `getCanvasMenuItems` | 4 | EXISTS — replaces canvas right-click monkey-patching |
| `getNodeMenuItems` | 4 | EXISTS — replaces node right-click monkey-patching (P6 in R4) |
| `onAuthUserResolved` | 1 | unchanged |
| `onAuthTokenRefreshed` | 1 | unchanged |
| `onAuthUserLogout` | 1 | unchanged |
### S2 — `LGraphNode.prototype` methods commonly patched
Already-confirmed (R4): `onNodeCreated`, `onExecuted`, `onConnectionsChange`, `onRemoved`, `getExtraMenuOptions`, `convertWidgetToInput`, `onGraphConfigured`, `onConfigure`, `onInputDblClick`.
Add to search: `onAdded`, `onSerialize`, `onDeserialize`, `onDrawForeground`, `onDrawBackground`, `onSelected`, `onDeselected`, `onMouseDown`, `onMouseEnter`, `onMouseLeave`, `onDblClick`, `onPropertyChanged`, `onWidgetChanged`, `onResize`, `onAction`, `onConnectInput`, `onConnectOutput`, `onConfigure`, `onWorkflowConfigure`, `onConnectionsChange`, `onConfigure`, `onCreate`, `clone`, `computeSize`.
### S3 — `LGraphCanvas.prototype` methods commonly patched
Confirmed (R4 P7): `processKey`, `processContextMenu`, `computeVisibleNodes`. Our own core: `processMouseDown`, `processMouseMove` (simpleTouchSupport.ts).
Add to search: `drawNode`, `drawNodeShape`, `drawConnections`, `onMouseDown`, `onDblClick`, `getCanvasMenuOptions`, `getNodeMenuOptions`, `getGroupMenuOptions`, `processNodeWidgets`, `selectNodes`, `deselectAllNodes`, `setSelectedNodes`.
### S4 — Widget-level patterns (the heart of widget-api-thoughts.md)
- `.callback` chaining (R4 P1) — the dominant value-change pattern
- `.value` direct reads/writes (R4 evidence: imageCompare, widgetInputs, customWidgets, saveImageExtraOutput)
- `.serializeValue` assignment (dynamicPrompts.v2 uses it)
- `.options.*` direct mutation
- `.computedHeight`, `.y`, `.last_y` — layout-level reads
- `.options.values` — combo widget values
- `.options.serialize`, `.options.hidden`, `.options.readonly` — option flags
- Custom widget types declared via `getCustomWidgets`
- `addDOMWidget(name, type, element, options)` — DOM widget contribution (R4 P9)
**Widget thoughts file flags lifecycle dependencies** (widget-api-thoughts.md:25-30):
- 3D widgets: file uploads
- Webcam widgets: heavy perf
- Webcam widgets: lifecycle-dependent serialization
- Widgets whose post-serialize value depends on lifecycle steps
These need explicit DB entries with `lifecycle_dependent: true` flag.
### S5 — `ComfyApi` / `app.api` event surfaces
Confirmed (R4 P8): `addEventListener('executed', …)`, custom `'extName.eventName'` events.
Add to search: `addEventListener('executing', …)`, `'progress'`, `'progress_state'`, `'status'`, `'reconnecting'`, `'reconnected'`, `'execution_start'`, `'execution_success'`, `'execution_error'`, `'execution_cached'`, `'b_preview'`, `'logs'`.
### S6 — `ComfyApp` god-object touch points
- `app.graph` — direct LiteGraph object access
- `app.canvas` — direct LGraphCanvas access
- `app.canvasManager` — newer wrapper
- `app.queuePrompt` — submit a workflow
- `app.graphToPrompt` — serialize current graph to API payload
- `app.loadGraphData` — load a workflow JSON
- `app.extensionManager` — ExtensionManager registry access
- `app.api` — see S5
- `app.getNodeDefs` — node definition registry
- `app.registerExtension` — the entry point itself
- `app.ui` — legacy UI shim
### S7 — Window / global escape hatches
- `window.app` — escape hatch documented in index.ts
- `window.graph` — escape hatch documented in index.ts
- `window.LiteGraph` — direct LiteGraph access
- `window.LGraphCanvas` — direct canvas class access
- `window.comfyAPI.modules[...]` — production-only shim mechanism (per extension-development-guide.md)
### S8 — Special node properties (magic flags)
- `nodeType.prototype.isVirtualNode` (R4 P10) — virtual node flag
- `nodeType.prototype.serialize_widgets` — serialization toggle
- `nodeType.prototype.color`, `bgcolor` — visual override
- `nodeType.prototype.shape` — node shape override
- `nodeType['@<input>']` — input-type metadata (Eclipse pattern)
- `nodeType.category` — menu category override
### S9 — Non-Node entity kinds (per ADR 0008)
ADR 0008 enumerates **six** entity kinds; the bulk of the ecosystem touches more than just `Node` and `Widget`. These touch points are largely undocumented in the v1 extension API.
- **Reroute** (`Reroute`, `RerouteId`) — `LiteGraph.createRerouteOnLink`, `graph.reroutes`, `node.connectByRerouteId`
- **Group** (`LGraphGroup`) — `graph.groups`, `group.color`, `group.font`, `group.font_size`, `group.children`
- **Link** (`LLink`, `LinkId`) — `link.color`, `link._pos`, `link._dragging`, `link.data`
- **Slot** (`SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) — `slot.color_on/_off`, `slot.shape`, `slot.dir`, `slot.localized_name`
- **Subgraph virtual nodes** — set/get virtual node trick (KJNodes), `nodeType.isVirtualNode = true` (S8) coupled with `graphToPrompt` rewriting (S6.A1)
### S10 — Dynamic node API (slot/connection mutation at runtime)
- `node.addInput(name, type)` / `node.removeInput(slot)` — runtime input mutation (typically inside `onConnectionsChange`)
- `node.addOutput(name, type)` / `node.removeOutput(slot)` — runtime output mutation
- `node.connect(srcSlot, target, dstSlot)` / `node.disconnectInput(slot)` / `node.disconnectOutput(slot)` — programmatic linking
- `node.findOutputSlot(name)` / `node.findInputSlot(name)` — slot lookup by name
- `node.setDirtyCanvas(true, true)` — force redraw (extremely common after any mutation)
- `node.collapse()` / `node.setSize([w,h])` — imperative geometry
### S11 — Graph-level state and change-tracking
- `graph._version++` and `graph._version` reads — change-tracking signal **(project AGENTS.md §5: affects 40+ repos)**
- `graph.add(node)` / `graph.remove(node)` / `graph.findNodesByType(type)` / `graph.findNodeById(id)`
- `graph.serialize()` / `graph.configure(json)` — full-graph serialization (related to S6.A1 graphToPrompt but distinct)
- `graph.beforeChange()` / `graph.afterChange()` — explicit batching seam
- `graph.onNodeAdded` / `graph.onNodeRemoved` / `graph.onNodeConnectionChange` — graph-level callbacks (vs per-node)
### S12 — Shell UI registries (sidebar / bottom panel / commands / toasts)
These are *declarative* surfaces in v1 (extensions push registrations) but their semantics are still public API. Migration must preserve names and contracts.
- `extensionManager.registerSidebarTab(...)``SidebarTabExtension`
- `extensionManager.registerBottomPanelTab(...)``BottomPanelExtension`
- `commandManager.registerCommand(...)``CommandManager`
- `toastManager.add(...)` / `toastManager.remove(...)``ToastManager`
- `app.registerExtension({ settings: [...] })` — Settings system contributions
- `app.registerExtension({ keybindings: [...] })` — Keybinding contributions
- `app.registerExtension({ commands: [...], menuCommands: [...] })` — Menu/command contributions
### S13 — Schema interpretation (`ComfyNodeDef` / `InputSpec`)
Extensions inspect the node-def schema directly to drive UI/behavior — this is a public API by accident.
- `nodeData.input.required` / `nodeData.input.optional` / `nodeData.input.hidden` — input bag inspection
- `nodeData.output[]` / `nodeData.output_name[]` / `nodeData.output_is_list[]` — output schema inspection
- `nodeData.output_node` — special "output node" boolean flag
- `nodeData.category` / `nodeData.python_module` — origin metadata
- `InputSpec` sentinel objects — `["INT", { default, min, max, step }]`, `["STRING", { multiline }]`, `["COMBO", { values, default }]`, `["IMAGEUPLOAD", {...}]`, etc.
### S14 — Identity / Locator scheme
- `NodeLocatorId` — encodes `(graphScope, nodeId)` for cross-subgraph references
- `NodeExecutionId` — backend execution-graph identifier
- `parseNodeLocatorId` / `createNodeLocatorId` / `isNodeLocatorId` — public helpers exported from `src/types/index.ts`
- Implicit pattern: extensions resolve "node X in subgraph Y" — must work after subgraph promotion
### S15 — Output system (per `widget-api-thoughts.md`)
`widget-api-thoughts.md` flags this as a separate change axis from widgets:
- Dynamic output mutation via `node.addOutput` / `node.removeOutput` (cross-references S10)
- Schema-declared outputs (preferred end-state) — `OUTPUT_TYPES`-style explicit declaration
- `nodeData.output_node` flag — node is a terminal/sink
- `node.onExecuted({ images: [...] })` — output-display pattern (cross-references S2.N2)
- "Force declaration" goal: extensions must declare output types in the node schema, not mutate at runtime
## Database schema
Each entry is a YAML record:
```yaml
- pattern_id: P1.1 # stable ID for cross-reference
surface_family: S4 # S1-S8
surface: "widget.callback assignment" # human-readable name
fingerprint: 'w.callback = function(v) {...}' # regex-ish
semantic: "subscribe to widget value change" # what extensions are *trying* to do
v2_replacement: "widget.on('change', fn)" # proposed
decision_ref: D3.3 # which decision doc covers it
test_target: WIDGET_VALUE_CHANGE_LISTENER # test framework symbol
evidence:
- repo: crom8505/ComfyUI-Dynamic-Sigmas
file: web/js/graph_sigmas.js
lines: [79, 80]
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
stars: 12 # github stars (cached, asof date)
stars_asof: 2026-05-06
variant: canonical # canonical | unsafe | with-bind | tempCallback-swap | per-instance | prototype
breakage_class: silent # silent | loud | undefined-behavior | crash
notes: "fourteen instances in same file"
derived:
occurrences: 7 # rolled up from evidence
repos_touched: 5
cumulative_stars: 245
canonical_signatures: 1 # how many distinct shapes seen (P4 had 6 for onConnectionsChange!)
breakage_classes: [silent, undefined-behavior]
blast_radius: 3.2 # see formula
```
## Blast-radius scoring formula
Goal: rank patterns by how disruptive their breakage would be in v2 rollout.
```
blast_radius = 0.40 * log10(1 + cumulative_stars)
+ 0.20 * log10(1 + occurrences)
+ 0.15 * canonical_signatures # more shapes = more migration cases to support
+ 0.15 * silent_breakage_weight # silent > loud > crash for danger
+ 0.10 * lifecycle_coupling # 0/1/2; widgets that break on serialize timing get 2
```
Where:
- `silent_breakage_weight` = max over evidence: silent=1.0, undefined=0.6, loud=0.3, crash=0.2
- `lifecycle_coupling` = 0 (none) | 1 (depends on init/teardown order) | 2 (depends on serialization-timing or DOM-mount-timing)
Rationale:
- `log10` on stars + occurrences damps mega-popular packs from drowning out long-tail diversity
- Silent breakage scores higher than loud — these are the ones that destroy trust
- Lifecycle coupling captures widget-api-thoughts.md concerns (3D, webcam)
- Canonical signatures captures "the API has no schema" risk (R4 P4 with 6 sigs)
A blast_radius ≥ 3.0 = MUST have a v1-compat shim or the migration story breaks.
## Star-fetching strategy
For each unique repo:
```bash
gh api "repos/<owner>/<name>" --jq '.stargazers_count'
```
Cache in `research/touch-points/star-cache.yaml`:
```yaml
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 12
asof: 2026-05-06
```
Refresh quarterly. If gh CLI errors (rate limit, repo gone), record `stars: null` and `error: <reason>`.
## Workflow
1. **Plan + schema (this doc)**
2. **Build initial database** — start with the 12 patterns from R4, structured properly
3. **Sweep S1S8 systematically** — batched code search, populate evidence
4. **Star fetch pass**`gh api` for every unique repo, populate cache
5. **Compute derived fields** — script that rolls up evidence into derived metrics
6. **Generate ranked report**`database-by-blast-radius.md`
7. **Map to test framework** — each pattern_id → test symbol
## Dispatch strategy for queries
- ~50 queries needed across S1S8 (each surface gets 1-3 queries)
- Run in parallel batches of 4-6 (MCP tolerates this if no DNS error)
- Retry failed queries with 3-token reformulations (R4 workaround)
- After each batch: append findings to `database.yaml`, never overwrite
- After full sweep: run star-fetch script, run roll-up script
## Integration with the test framework
Each pattern in the database becomes a test triple:
1. **v1 contract test** (legacy): proves the v1 hook still works for shimmed extensions
2. **v2 contract test** (new): proves the v2 replacement covers the same semantic
3. **Migration test**: takes a real extension snippet from evidence, confirms it works in v2 (or fails with a documented compat error)
The test framework's "compatibility floor" is: every blast_radius ≥ 2.0 entry MUST pass all three tests before v2 ships.
## Out of scope (deferred)
- Sandboxing model (Chrome-extension-style isolation): noted in CONTEXT.md, deferred
- Performance benchmarks vs v1: separate workstream
- Documentation generation from the database: separate workstream
- npm package design for `@comfyui/extension-api`: separate workstream (per R4 P11 finding)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
# ───────────────────────────────────────────────────────────────────────
# GitHub star cache for repos referenced in database.yaml
# Refresh: bash scripts/fetch-stars.sh
# Asof dates allow drift detection
# ───────────────────────────────────────────────────────────────────────
asof: 2026-05-08
populated_via: scripts/fetch-stars.sh
repos:
- repo: 40740/ComfyUI_LayerStyle_Bmss
stars: 0
archived: false
forks: 0
last_commit: 2024-10-16
asof: 2026-05-08
- repo: 834t/ComfyUI_834t_scene_composer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-03
asof: 2026-05-08
- repo: aicocoa981/WhatDreamsCost-ComfyUI-private
stars: 1
archived: false
forks: 0
last_commit: 2026-03-30
asof: 2026-05-08
- repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
stars: 405
archived: false
forks: 25
last_commit: 2025-10-27
asof: 2026-05-08
- repo: akawana/ComfyUI-Folded-Prompts
stars: 4
archived: false
forks: 2
last_commit: 2026-04-30
asof: 2026-05-08
- repo: AkihaTatsu/ComfyUI-Simple-Utility-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-11
asof: 2026-05-08
- repo: alankent/ComfyUI-OA-360-Clip
stars: 0
archived: false
forks: 1
last_commit: 2025-11-16
asof: 2026-05-08
- repo: AlexZ1967/ComfyUI_ALEXZ_tools
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ameliacode/comfyui-face3d
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: andreszs/ComfyUI-Ultralytics-Studio
stars: 3
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: ArtHommage/HommageTools
stars: 4
archived: false
forks: 1
last_commit: 2025-05-20
asof: 2026-05-08
- repo: Azornes/Comfyui-LayerForge
stars: 312
archived: false
forks: 16
last_commit: 2026-05-01
asof: 2026-05-08
- repo: becky3/comfyui-workspace
stars: 0
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: BennyKok/comfyui-deploy
stars: 1508
archived: false
forks: 222
last_commit: 2025-11-13
asof: 2026-05-08
- repo: brycecovert/ComfyUI-compass-images
stars: 0
archived: false
forks: 0
last_commit: 2026-04-23
asof: 2026-05-08
- repo: choovin/comfyui-api
stars: 3
archived: false
forks: 0
last_commit: 2026-03-08
asof: 2026-05-08
- repo: chyer/Chye-ComfyUI-Toolset
stars: 0
archived: false
forks: 1
last_commit: 2026-03-10
asof: 2026-05-08
- repo: coeuskoalemoss/comfyUI-layerstyle-custom
stars: 0
archived: false
forks: 0
last_commit: 2025-06-23
asof: 2026-05-08
- repo: ComfyNodePRs/PR-comfyui-pkg39-ccab78b5
stars: 0
archived: false
forks: 0
last_commit: 2024-07-31
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI_frontend
stars: 1787
archived: false
forks: 563
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-Manager
stars: 14564
archived: false
forks: 2187
last_commit: 2026-05-08
asof: 2026-05-08
- repo: Comfy-Org/ComfyUI-test-framework
stars: 2
archived: false
forks: 1
last_commit: 2026-03-23
asof: 2026-05-08
- repo: ComfyUI-Kelin/ComfyUI_Image_Anything
stars: 3
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: Creepybits/ComfyUI-Creepy_nodes
stars: 29
archived: false
forks: 5
last_commit: 2026-04-14
asof: 2026-05-08
- repo: criskb/Comfypencil
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: criskb/Fancy_Grid
stars: 0
archived: false
forks: 1
last_commit: 2026-04-13
asof: 2026-05-08
- repo: crom8505/ComfyUI-Dynamic-Sigmas
stars: 8
archived: false
forks: 2
last_commit: 2026-03-30
asof: 2026-05-08
- repo: Damkohler/jlc-comfyui-nodes
stars: 16
archived: false
forks: 4
last_commit: 2026-04-17
asof: 2026-05-08
- repo: darth-veitcher/comfyui-ollama-model-manager
stars: 1
archived: false
forks: 1
last_commit: 2025-11-05
asof: 2026-05-08
- repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
stars: 7
archived: false
forks: 2
last_commit: 2026-04-22
asof: 2026-05-08
- repo: diodiogod/TTS-Audio-Suite
stars: 911
archived: false
forks: 101
last_commit: 2026-05-08
asof: 2026-05-08
- repo: dorpxam/ComfyUI-LTX2-Microscope
stars: 4
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: DumiFlex/ComfyUI-Wildcard-Pipeline
stars: 4
archived: false
forks: 2
last_commit: 2026-04-08
asof: 2026-05-08
- repo: egormly/ComfyUI-EG_Tools
stars: 0
archived: false
forks: 0
last_commit: 2025-11-19
asof: 2026-05-08
- repo: EmanuelRiquelme/comfyui-art-venture
stars: 0
archived: false
forks: 0
last_commit: 2024-09-04
asof: 2026-05-08
- repo: EnragedAntelope/EA_LMStudio
stars: 7
archived: false
forks: 4
last_commit: 2026-04-22
asof: 2026-05-08
- repo: Firetheft/ComfyUI-Animate-Progress
stars: 3
archived: false
forks: 1
last_commit: 2025-09-09
asof: 2026-05-08
- repo: FloyoAI/ComfyUI-SoundFlow
stars: 0
archived: false
forks: 0
last_commit: 2025-11-21
asof: 2026-05-08
- repo: flymyd/koishi-plugin-comfyui-client
stars: 0
archived: false
forks: 1
last_commit: 2025-08-21
asof: 2026-05-08
- repo: FunnyFinger/Dynamic_Sliders_stack
stars: 4
archived: false
forks: 2
last_commit: 2025-04-22
asof: 2026-05-08
- repo: gigici/ComfyUI_BlendPack
stars: 1
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: goodtab/ComfyUI-Custom-Scripts
stars: 0
archived: false
forks: 0
last_commit: 2024-09-02
asof: 2026-05-08
- repo: guido-gfv/gfv_pro_upgrade
stars: 0
archived: false
forks: 0
last_commit: 2026-04-20
asof: 2026-05-08
- repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
stars: 0
archived: false
forks: 1
last_commit: 2024-05-22
asof: 2026-05-08
- repo: hernantech/comfymcp
stars: 0
archived: false
forks: 0
last_commit: 2026-01-03
asof: 2026-05-08
- repo: hhayiyuan/ComfyUI-FFmpegURLMedia
stars: 2
archived: false
forks: 2
last_commit: 2026-01-02
asof: 2026-05-08
- repo: huafitwjb/ComfyUI-GO-Mobile-app
stars: 6
archived: false
forks: 1
last_commit: 2026-03-04
asof: 2026-05-08
- repo: ialhabbal/compare
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: IAMCCS/IAMCCS-nodes
stars: 92
archived: false
forks: 6
last_commit: 2026-05-04
asof: 2026-05-08
- repo: IXIWORKS-KIMJUNGHO/comfyui-ixiworks-tools
stars: 1
archived: false
forks: 2
last_commit: 2026-03-15
asof: 2026-05-08
- repo: JichaoLiang/Immortal_comfyui_public
stars: 0
archived: false
forks: 0
last_commit: 2025-12-05
asof: 2026-05-08
- repo: jiekouai/ComfyUI-JieKou-API
stars: 1
archived: false
forks: 0
last_commit: 2026-03-25
asof: 2026-05-08
- repo: jonstreeter/comfyui-compressed-metadata
stars: 0
archived: false
forks: 1
last_commit: 2025-10-12
asof: 2026-05-08
- repo: ketle-man/ComfyUI-Workflow-Studio
stars: 2
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: kijai/ComfyUI-KJNodes
stars: 2569
archived: false
forks: 292
last_commit: 2026-05-08
asof: 2026-05-08
- repo: koshimazaki/ComfyUI-Koshi-Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-03-12
asof: 2026-05-08
- repo: krismasdev/ComfyUI-Flux-Continuum
stars: 0
archived: false
forks: 0
last_commit: 2025-08-04
asof: 2026-05-08
- repo: KumihoIO/kumiho-plugins
stars: 2
archived: false
forks: 0
last_commit: 2026-03-28
asof: 2026-05-08
- repo: kyuz0/amd-strix-halo-comfyui-toolboxes
stars: 109
archived: false
forks: 14
last_commit: 2026-02-13
asof: 2026-05-08
- repo: LaoMaoBoss/ComfyUI-WBLESS
stars: 0
archived: false
forks: 1
last_commit: 2026-04-07
asof: 2026-05-08
- repo: Lightricks/ComfyUI-LTXVideo
stars: 3587
archived: false
forks: 390
last_commit: 2026-04-26
asof: 2026-05-08
- repo: linjm8780860/ljm_comfyui
stars: 0
archived: false
forks: 0
last_commit: 2026-04-29
asof: 2026-05-08
- repo: lordwedggie/xcpNodes
stars: 0
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: lucafoscili/lf-nodes
stars: 34
archived: false
forks: 3
last_commit: 2025-12-23
asof: 2026-05-08
- repo: m3rr/h4_Live
stars: 2
archived: false
forks: 2
last_commit: 2026-05-07
asof: 2026-05-08
- repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
stars: 97
archived: false
forks: 6
last_commit: 2026-05-08
asof: 2026-05-08
- repo: max-dingsda/ComfyUI-AllinOne-LazyNode
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: maxi45274/ComfyUI_LinkFX
stars: 3
archived: false
forks: 0
last_commit: 2026-05-08
asof: 2026-05-08
- repo: melMass/comfy_mtb
stars: 702
archived: false
forks: 82
last_commit: 2026-03-19
asof: 2026-05-08
- repo: MockbaTheBorg/ComfyUI-Mockba
stars: 1
archived: false
forks: 0
last_commit: 2026-04-13
asof: 2026-05-08
- repo: mudknight/comfyui-mudknight-utils
stars: 0
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: MuhammadMuradKhan/efficiency-nodes-comfyui
stars: 0
archived: false
forks: 0
last_commit: 2024-02-11
asof: 2026-05-08
- repo: niknah/presentation-ComfyUI
stars: 2
archived: false
forks: 2
last_commit: 2026-04-21
asof: 2026-05-08
- repo: nodelee733/ComfyUI-mxToolkit
stars: 0
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: nodetool-ai/nodetool
stars: 332
archived: false
forks: 40
last_commit: 2026-05-08
asof: 2026-05-08
- repo: nvmax/aspect-ratio-resizer
stars: 5
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
stars: 45
archived: false
forks: 4
last_commit: 2025-08-09
asof: 2026-05-08
- repo: PGCRT/CRT-Nodes
stars: 108
archived: false
forks: 14
last_commit: 2026-05-03
asof: 2026-05-08
- repo: philippjbauer/devint25-comfyui-api-demo
stars: 0
archived: false
forks: 0
last_commit: 2025-10-09
asof: 2026-05-08
- repo: pictorialink/ComfyUI-Easy-Use
stars: 1
archived: false
forks: 0
last_commit: 2025-07-15
asof: 2026-05-08
- repo: PioneerMNDR/ComfyUI-Polza
stars: 0
archived: false
forks: 1
last_commit: 2026-04-23
asof: 2026-05-08
- repo: pixaroma/ComfyUI-Pixaroma
stars: 146
archived: false
forks: 9
last_commit: 2026-05-08
asof: 2026-05-08
- repo: PROJECTMAD/PROJECT-MAD-NODES
stars: 4
archived: false
forks: 1
last_commit: 2026-04-01
asof: 2026-05-08
- repo: Raykosan/ComfyUI_RaykoStudio
stars: 45
archived: false
forks: 2
last_commit: 2026-05-05
asof: 2026-05-08
- repo: rgthree/rgthree-comfy
stars: 3054
archived: false
forks: 226
last_commit: 2026-04-07
asof: 2026-05-08
- repo: robertvoy/ComfyUI-Distributed
stars: 544
archived: false
forks: 57
last_commit: 2026-04-26
asof: 2026-05-08
- repo: rohapa/comfyui-replay
stars: 0
archived: false
forks: 0
last_commit: 2026-03-27
asof: 2026-05-08
- repo: r-vage/ComfyUI_Eclipse
stars: 19
archived: false
forks: 2
last_commit: 2026-05-08
asof: 2026-05-08
- repo: ryanontheinside/ComfyUI_RyanOnTheInside
stars: 801
archived: false
forks: 50
last_commit: 2026-03-20
asof: 2026-05-08
- repo: sammykumar/ComfyUI-SwissArmyKnife
stars: 5
archived: false
forks: 1
last_commit: 2026-01-14
asof: 2026-05-08
- repo: SaturMars/ComfyUI-NVVFR
stars: 0
archived: false
forks: 1
last_commit: 2025-08-05
asof: 2026-05-08
- repo: ShakerSmith/ShakerNodesSuite
stars: 8
archived: false
forks: 0
last_commit: 2026-02-18
asof: 2026-05-08
- repo: shrimbly/willie-comfy-frontend
stars: 0
archived: false
forks: 0
last_commit: 2026-04-28
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SKBundle
stars: 116
archived: false
forks: 7
last_commit: 2026-04-23
asof: 2026-05-08
- repo: SKBv0/ComfyUI_SpideyReroute
stars: 13
archived: false
forks: 2
last_commit: 2025-12-19
asof: 2026-05-08
- repo: sofakid/dandy
stars: 54
archived: false
forks: 4
last_commit: 2025-12-15
asof: 2026-05-08
- repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
stars: 0
archived: false
forks: 1
last_commit: 2026-03-30
asof: 2026-05-08
- repo: SparknightLLC/ComfyUI-EnumCombo
stars: 2
archived: false
forks: 1
last_commit: 2026-05-08
asof: 2026-05-08
- repo: StableLlama/ComfyUI-basic_data_handling
stars: 43
archived: false
forks: 7
last_commit: 2026-05-07
asof: 2026-05-08
- repo: stavzszn/comfyui-teskors-utils
stars: 0
archived: false
forks: 0
last_commit: 2026-03-29
asof: 2026-05-08
- repo: Stibo/comfyui-nifty-nodes
stars: 3
archived: false
forks: 1
last_commit: 2026-03-21
asof: 2026-05-08
- repo: Sunwood-ai-labs/ComfyUI-LTXLongAudio
stars: 0
archived: false
forks: 0
last_commit: 2026-04-01
asof: 2026-05-08
- repo: sypex6/ComfyUI_InstaRAW_Nodes
stars: 0
archived: false
forks: 0
last_commit: 2026-04-07
asof: 2026-05-08
- repo: tavyra/ComfyUI_Curves
stars: 1
archived: false
forks: 1
last_commit: 2025-05-08
asof: 2026-05-08
- repo: tetsuoo-online/Comfyui-TOO-Pack
stars: 4
archived: false
forks: 2
last_commit: 2026-04-02
asof: 2026-05-08
- repo: touge/ComfyUI-NCE_Utils
stars: 0
archived: false
forks: 0
last_commit: 2026-01-25
asof: 2026-05-08
- repo: treforyan-hue/comfyui-deploy
stars: 0
archived: false
forks: 0
last_commit: 2026-05-05
asof: 2026-05-08
- repo: Valiant-Cat/ComfyUI-WanMove-Trajectory
stars: 1
archived: false
forks: 0
last_commit: 2025-12-12
asof: 2026-05-08
- repo: viswamohankomati/ComfyUI-Copilot
stars: 0
archived: false
forks: 0
last_commit: 2025-09-17
asof: 2026-05-08
- repo: vjumpkung/comfyui-infinitetalk-native-sampler
stars: 1
archived: false
forks: 2
last_commit: 2026-03-31
asof: 2026-05-08
- repo: Winnougan/WINT8-ComfyUI
stars: 1
archived: false
forks: 2
last_commit: 2026-04-17
asof: 2026-05-08
- repo: xeinherjer-dev/ComfyUI-XENodes
stars: 1
archived: false
forks: 1
last_commit: 2026-05-07
asof: 2026-05-08
- repo: yardimli/SafetensorViewer
stars: 7
archived: false
forks: 0
last_commit: 2025-02-19
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use
stars: 2504
archived: false
forks: 195
last_commit: 2026-04-29
asof: 2026-05-08
- repo: yolain/ComfyUI-Easy-Use-Frontend
stars: 27
archived: false
forks: 9
last_commit: 2026-04-01
asof: 2026-05-08
- repo: yorkane/ComfyUI-KYNode
stars: 10
archived: false
forks: 4
last_commit: 2026-02-04
asof: 2026-05-08
- repo: zhupeter010903/ComfyUI-XYZ-prompt-library
stars: 1
archived: false
forks: 0
last_commit: 2026-04-26
asof: 2026-05-08
- repo: zzggi2024/shaobkj
stars: 1
archived: false
forks: 0
last_commit: 2026-04-27
asof: 2026-05-08
- repo: zzw5516/ComfyUI-zw-tools
stars: 6
archived: false
forks: 1
last_commit: 2025-12-03
asof: 2026-05-08

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.18",
"version": "1.45.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -47,6 +47,9 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:extension-api": "vitest run --config vitest.extension-api.config.mts",
"test:extension-api:watch": "vitest --config vitest.extension-api.config.mts",
"test:extension-api:coverage": "vitest run --config vitest.extension-api.config.mts --coverage",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

3
packages/extension-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
docs-build/
build/
node_modules/

View File

@@ -0,0 +1,50 @@
# @comfyorg/extension-api
> **Status**: scaffolded. Package implementation pending PKG3 — see
> `../../../plans/P2-extension-api-package.md` and
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
The official TypeScript declaration package for ComfyUI extensions. This
package replaces the practice of vendoring `comfy.d.ts` files in custom
node repos.
## Install (post-publish)
```bash
pnpm add -D @comfyorg/extension-api
```
```ts
import { defineExtension } from '@comfyorg/extension-api'
export default defineExtension({
name: 'MyExtension',
setup(ctx) {
ctx.onNodeMounted((node) => {
// ...
})
}
})
```
## Source
This package is built from the source-of-truth folder
`../../src/extension-api/`. Do not edit the package's `build/` output
directly.
## Versioning
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
surface has stabilized.
- Breaking changes follow semver strictly from `1.0.0` onward.
## Cross-references
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
- `plans/P2-extension-api-package.md` — package structure plan
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration

View File

@@ -0,0 +1,28 @@
{
"name": "@comfyorg/extension-api",
"version": "0.1.0",
"description": "Official TypeScript extension API for ComfyUI custom nodes",
"type": "module",
"exports": {
".": "./build/index.js"
},
"types": "./build/index.d.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --emitDeclarationOnly --outDir build",
"docs:build": "tsx scripts/build-docs.ts",
"docs:watch": "tsx scripts/build-docs.ts --watch"
},
"devDependencies": {
"tsx": "catalog:",
"typedoc": "0.28.19",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:api"
]
}
}

View File

@@ -0,0 +1,470 @@
#!/usr/bin/env tsx
/**
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
*
* Steps:
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
* 2. Post-process each markdown file:
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
* - Convert ``` fences without lang tag → ```ts
* - Replace raw [TypeName] cross-refs with MDX relative links
* - Wrap @example blocks in proper code fences
* 3. Write final .mdx files to docs-build/mintlify/
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
*
* Run: pnpm --filter @comfyorg/extension-api docs:build
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgRoot = path.resolve(__dirname, '..')
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
const watchMode = process.argv.includes('--watch')
// ── Page metadata ────────────────────────────────────────────────────────────
// Controls frontmatter for each generated page. Key = TypeDoc output filename
// stem (lowercased). Unrecognised files get generic metadata.
interface PageMeta {
title: string
sidebarTitle?: string
description: string
icon?: string
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
order: number
}
const PAGE_META: Record<string, PageMeta> = {
// Top-level overview
index: {
title: 'Extension API Overview',
description: 'TypeScript API reference for ComfyUI custom node extensions.',
icon: 'puzzle-piece',
group: 'root',
order: 0
},
// Lifecycle / registration
defineextension: {
title: 'defineExtension',
description: 'Register an app-scoped extension for init, setup, and shell UI contributions.',
icon: 'code',
group: 'core',
order: 1
},
definenodeextension: {
title: 'defineNodeExtension',
description: 'Register a node-scoped extension reacting to node lifecycle events.',
icon: 'code',
group: 'core',
order: 2
},
definewidgetextension: {
title: 'defineWidgetExtension',
description: 'Register a custom widget type with its own DOM rendering.',
icon: 'code',
group: 'core',
order: 3
},
extensionoptions: {
title: 'ExtensionOptions',
description: 'Options object for defineExtension — app-wide lifecycle and shell UI.',
group: 'core',
order: 4
},
nodeextensionoptions: {
title: 'NodeExtensionOptions',
description: 'Options object for defineNodeExtension — node lifecycle hooks.',
group: 'core',
order: 5
},
widgetextensionoptions: {
title: 'WidgetExtensionOptions',
description: 'Options object for defineWidgetExtension — custom widget rendering.',
group: 'core',
order: 6
},
onnoderemoved: {
title: 'onNodeRemoved',
sidebarTitle: 'onNodeRemoved',
description: 'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
group: 'core',
order: 7
},
onnodemounted: {
title: 'onNodeMounted',
sidebarTitle: 'onNodeMounted',
description: 'Implicit-context lifecycle hook: fires when a node is fully mounted.',
group: 'core',
order: 8
},
// Handles
nodehandle: {
title: 'NodeHandle',
description: 'Controlled access to node state, mutations, slots, and events.',
icon: 'circle-nodes',
group: 'handles',
order: 10
},
widgethandle: {
title: 'WidgetHandle',
description: 'Controlled access to widget state, mutations, and events.',
icon: 'sliders',
group: 'handles',
order: 11
},
slotinfo: {
title: 'SlotInfo',
description: 'Read-only snapshot of a node slot (input or output).',
group: 'handles',
order: 12
},
// Events
nodeexecutedevent: {
title: 'NodeExecutedEvent',
description: 'Payload fired when a node finishes execution.',
group: 'events',
order: 20
},
nodeconnectedevent: {
title: 'NodeConnectedEvent',
description: 'Payload fired when a slot connection is made.',
group: 'events',
order: 21
},
nodedisconnectedevent: {
title: 'NodeDisconnectedEvent',
description: 'Payload fired when a slot connection is removed.',
group: 'events',
order: 22
},
nodepositionchangedevent: {
title: 'NodePositionChangedEvent',
description: 'Payload fired when a node is moved on the canvas.',
group: 'events',
order: 23
},
nodesizechangedevent: {
title: 'NodeSizeChangedEvent',
description: 'Payload fired when a node is resized.',
group: 'events',
order: 24
},
nodemodechangedevent: {
title: 'NodeModeChangedEvent',
description: 'Payload fired when a node execution mode changes.',
group: 'events',
order: 25
},
nodebeforeserializeevent: {
title: 'NodeBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip node data.',
group: 'events',
order: 26
},
widgetvaluechangeevent: {
title: 'WidgetValueChangeEvent',
description: 'Payload fired when a widget value changes.',
group: 'events',
order: 27
},
widgetbeforeserializeevent: {
title: 'WidgetBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip widget value.',
group: 'events',
order: 28
},
widgetbeforequeueevent: {
title: 'WidgetBeforeQueueEvent',
description: 'Pre-queue validation payload — call reject() to cancel queue.',
group: 'events',
order: 29
},
// Shell UI
sidebartabextension: {
title: 'SidebarTabExtension',
description: 'Register a custom sidebar tab.',
group: 'shell',
order: 40
},
bottompanelextension: {
title: 'BottomPanelExtension',
description: 'Register a custom bottom panel tab.',
group: 'shell',
order: 41
},
toastmanager: {
title: 'ToastManager',
description: 'Show toast notifications to the user.',
group: 'shell',
order: 42
},
commandmanager: {
title: 'CommandManager',
description: 'Register keyboard shortcuts and command palette entries.',
group: 'shell',
order: 43
},
extensionmanager: {
title: 'ExtensionManager',
description: 'Access shell UI registration APIs.',
group: 'shell',
order: 44
},
// Identity
nodelocatorid: {
title: 'NodeLocatorId',
description: 'Branded string ID that uniquely locates a node across graph snapshots.',
group: 'identity',
order: 50
},
nodeexecutionid: {
title: 'NodeExecutionId',
description: 'Branded string ID for a specific node execution run.',
group: 'identity',
order: 51
}
}
const GROUP_LABELS: Record<PageMeta['group'], string> = {
root: 'Extensions API',
core: 'Registration',
handles: 'Handles',
events: 'Events',
shell: 'Shell UI',
identity: 'Identity'
}
// ── Utilities ────────────────────────────────────────────────────────────────
function slug(stem: string): string {
return stem.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function metaFor(stem: string): PageMeta {
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
return (
PAGE_META[key] ?? {
title: stem,
description: `API reference for ${stem}.`,
group: 'core',
order: 99
}
)
}
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
function toMintlifyMdx(raw: string, stem: string): string {
const meta = metaFor(stem)
// Build frontmatter
const fm: string[] = [
`---`,
`title: "${meta.title}"`,
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
`description: "${meta.description}"`,
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
`---`
]
let body = raw
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
body = body.replace(/^# .+\n+/, '')
// Ensure opening code fences that have no lang tag get `ts`
// Only match a ``` that is immediately followed by a newline (opening fence),
// not a closing fence (which also has just ``` + newline but we can detect
// by context: opening fences follow non-fence lines; closing fences follow content).
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
// We track state via a flag pass instead of a single regex.
let inBlock = false
body = body
.split('\n')
.map((line) => {
if (inBlock) {
if (line.trim() === '```') { inBlock = false; return line }
return line
}
if (line.startsWith('```')) {
if (line.trim() === '```') {
// bare opening fence → add ts
inBlock = true
return '```ts'
}
// has a lang tag already
inBlock = true
return line
}
return line
})
.join('\n')
// TypeDoc emits `typescript` lang tag; normalize to `ts`
body = body.replace(/^```typescript\b/gm, '```ts')
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
body = body.replace(
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
(_match, label, _category, file) => `[${label}](./${slug(file)})`
)
// Same-dir links
body = body.replace(
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
(_match, label, file) => `[${label}](./${slug(file)})`
)
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
// code examples to be directly under prose without a sub-heading.
// Flatten "## Example\n\n```ts" → "```ts"
body = body.replace(/^## Example\s*\n+/gm, '')
// Stability tags: render as a <Tip> callout
body = body.replace(
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
(_match, level) => {
const label =
level === 'stable'
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
: level === 'experimental'
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
return label
}
)
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
body = body.replace(
/^Stability: (stable|experimental|deprecated)\s*$/gm,
(_match, level) => {
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
if (level === 'experimental') return '<Warning>**Stability:** Experimental</Warning>'
return '<Warning>**Stability:** Deprecated</Warning>'
}
)
return [...fm, '', body.trim(), ''].join('\n')
}
// ── Nav snippet builder ───────────────────────────────────────────────────────
interface NavPage {
group?: string
pages: (string | NavPage)[]
}
function buildNavSnippet(stems: string[]): NavPage {
const byGroup: Record<string, string[]> = {}
for (const stem of stems) {
const meta = metaFor(stem)
const group = meta.group
if (!byGroup[group]) byGroup[group] = []
byGroup[group].push(`extensions/api/${slug(stem)}`)
}
// Sort each group by order
const sortedStems = stems.slice().sort((a, b) => metaFor(a).order - metaFor(b).order)
const sortedByGroup: Record<string, string[]> = {}
for (const stem of sortedStems) {
const group = metaFor(stem).group
if (!sortedByGroup[group]) sortedByGroup[group] = []
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
}
const groupOrder: PageMeta['group'][] = ['root', 'core', 'handles', 'events', 'shell', 'identity']
const pages: (string | NavPage)[] = []
// Overview at top level
if (sortedByGroup['root']) {
for (const p of sortedByGroup['root']) pages.push(p)
}
for (const grp of groupOrder) {
if (grp === 'root') continue
const grpPages = sortedByGroup[grp]
if (!grpPages?.length) continue
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
}
return { group: 'Extensions API', pages }
}
// ── Main pipeline ────────────────────────────────────────────────────────────
function runTypedoc(): void {
console.log('▶ Running TypeDoc...')
execSync(
`npx typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
{ cwd: pkgRoot, stdio: 'inherit' }
)
}
function processFiles(): void {
if (!fs.existsSync(rawDir)) {
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
}
fs.mkdirSync(mintlifyDir, { recursive: true })
const mdFiles = fs.readdirSync(rawDir, { recursive: true })
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
const stems: string[] = []
for (const relPath of mdFiles) {
const src = path.join(rawDir, relPath)
const stem = path.basename(relPath, '.md')
const raw = fs.readFileSync(src, 'utf8')
const mdx = toMintlifyMdx(raw, stem)
const destName = slug(stem) + '.mdx'
const dest = path.join(mintlifyDir, destName)
fs.writeFileSync(dest, mdx)
console.log(`${relPath} → mintlify/${destName}`)
stems.push(stem)
}
// Write nav snippet
const nav = buildNavSnippet(stems)
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
console.log(` ✔ nav-snippet.json`)
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
console.log(` ${stems.length} pages + nav-snippet.json`)
}
function run(): void {
runTypedoc()
processFiles()
}
if (watchMode) {
// Simple watch: re-run on change to source files
console.log('👁 Watch mode — watching src/extension-api/**')
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
let debounce: ReturnType<typeof setTimeout> | null = null
run()
fs.watch(srcDir, { recursive: true }, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
console.log('\n🔄 Source changed — rebuilding...')
try { run() } catch (e) { console.error(e) }
}, 500)
})
} else {
run()
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["../../src/*"]
}
},
"include": [
"../../src/extension-api/**/*.ts"
],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue"
]
}

View File

@@ -0,0 +1,37 @@
{
"entryPoints": ["../../src/extension-api/index.ts"],
"tsconfig": "./tsconfig.docs.json",
"out": "./docs-build/raw",
"plugin": ["typedoc-plugin-markdown"],
"excludeInternal": true,
"excludePrivate": true,
"excludeProtected": true,
"readme": "none",
"skipErrorChecking": true,
"githubPages": false,
"blockTags": ["@stability", "@packageDocumentation", "@example", "@typeParam", "@returns", "@deprecated", "@remarks"],
"hideGenerator": true,
"useCodeBlocks": true,
"flattenOutputFiles": false,
"entryFileName": "index",
"fileExtension": ".md",
"outputFileStrategy": "members",
"hidePageHeader": false,
"hideBreadcrumbs": false,
"useHTMLAnchors": false,
"sanitizeComments": true,
"expandObjects": false,
"parametersFormat": "table",
"propertiesFormat": "table",
"typeDeclarationFormat": "table",
"indexFormat": "table",
"tableColumnSettings": {
"hideDefaults": false,
"hideInherited": false,
"hideModifiers": false,
"hideOverrides": false,
"hideSources": true,
"hideValues": false,
"leftAlignHeaders": false
}
}

View File

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

View File

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

View File

@@ -40,7 +40,10 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -48,6 +51,7 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -62,6 +66,7 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -76,6 +81,7 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -92,6 +98,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -0,0 +1,192 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } },
missingWarn: false,
fallbackWarn: false
})
const Body = defineComponent({
name: 'Body',
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
})
}
describe('GlobalDialog renderer branching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders the PrimeVue branch when renderer is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'primevue-default',
title: 'PrimeVue dialog',
component: Body
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
})
it('renders the Reka branch when renderer is reka', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-opt-in',
title: 'Reka dialog',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
const dialogs = await screen.findAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
})
it('preserves the renderer flag on the dialog stack item', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-flag-check',
title: 'Reka',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
const item = store.dialogStack.find((d) => d.key === 'reka-flag-check')
expect(item?.dialogComponentProps.renderer).toBe('reka')
})
})
describe('GlobalDialog Reka parity with PrimeVue', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('omits the close button when closable is false', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-not-closable',
title: 'No close',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
})
it('renders the close button by default', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-closable',
title: 'Closable',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('omits the title when headless is true', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-headless',
title: 'Hidden title',
component: Body,
dialogComponentProps: { renderer: 'reka', headless: true }
})
await screen.findByRole('dialog')
expect(screen.queryByText('Hidden title')).toBeNull()
})
it('renders the title when headless is omitted', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-titled',
title: 'Visible title',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.getByText('Visible title')).toBeInTheDocument()
})
it('closes the dialog on Escape by default', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-default',
title: 'Esc closes',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-default')).toBe(false)
})
it('does not close on Escape when closable is false', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-esc-blocked',
title: 'Esc blocked',
component: Body,
dialogComponentProps: { renderer: 'reka', closable: false }
})
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})

View File

@@ -1,49 +1,106 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<template v-for="item in dialogStore.dialogStack" :key="item.key">
<Dialog
v-if="isRekaItem(item)"
:open="item.visible"
:modal="item.dialogComponentProps.modal ?? true"
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay />
<DialogContent
:size="item.dialogComponentProps.size ?? 'md'"
:aria-labelledby="item.key"
@escape-key-down="
(e) =>
item.dialogComponentProps.closeOnEscape === false &&
e.preventDefault()
"
@pointer-down-outside="
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
<PrimeDialog
v-else
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</PrimeDialog>
</template>
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
@@ -53,6 +110,14 @@ const teamWorkspacesEnabled = computed(
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
return item.dialogComponentProps.renderer === 'reka'
}
function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -42,4 +43,43 @@ describe('ConfirmationDialogContent', () => {
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
it('omits the Cancel button when type is dirtyClose', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.queryByText('g.cancel')).not.toBeInTheDocument()
expect(screen.getByText('g.save')).toBeInTheDocument()
})
it('uses the provided denyLabel for the deny button on dirtyClose', () => {
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
expect(screen.queryByText('g.no')).not.toBeInTheDocument()
})
it('calls onConfirm(false) when deny is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({
type: 'dirtyClose',
denyLabel: 'Close anyway',
onConfirm
})
await userEvent.click(screen.getByRole('button', { name: 'Close anyway' }))
expect(onConfirm).toHaveBeenCalledWith(false)
})
it('calls onConfirm(true) when save is clicked on dirtyClose', async () => {
const onConfirm = vi.fn()
renderComponent({ type: 'dirtyClose', onConfirm })
await userEvent.click(screen.getByRole('button', { name: 'g.save' }))
expect(onConfirm).toHaveBeenCalledWith(true)
})
it('falls back to "no" label when denyLabel is not provided', () => {
renderComponent({ type: 'dirtyClose' })
expect(screen.getByText('g.no')).toBeInTheDocument()
})
})

View File

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

View File

@@ -1,5 +1,8 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -15,10 +18,12 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -238,6 +243,7 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -314,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -337,6 +344,8 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,7 +9,10 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
@@ -46,6 +49,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
const { presetNames, contentStyle } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

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

View File

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

View File

@@ -21,20 +21,42 @@
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
:model-value="selectedSpeed != null ? String(selectedSpeed) : undefined"
@update:model-value="(val) => (selectedSpeed = Number(val))"
>
<SelectTrigger size="md" class="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in speedOptions"
:key="opt.value"
:value="String(opt.value)"
>
{{ opt.name }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
:model-value="
selectedAnimation != null ? String(selectedAnimation) : undefined
"
@update:model-value="(val) => (selectedAnimation = Number(val))"
>
<SelectTrigger size="md" class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="anim in animations"
:key="anim.index"
:value="String(anim.index)"
>
{{ anim.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
@@ -54,10 +76,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }

View File

@@ -5,20 +5,20 @@ import { ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
role="slider"
:value="modelValue"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -15,21 +15,22 @@
class="absolute top-0 left-12 w-[150px] rounded-lg bg-interface-menu-surface p-4 shadow-lg"
>
<Slider
v-model="value"
:model-value="sliderValue"
class="w-full"
:min="min"
:max="max"
:step="step"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
const {
icon = 'pi-expand',
@@ -47,6 +48,12 @@ const {
const value = defineModel<number>()
const showSlider = ref(false)
const sliderValue = computed(() => [value.value ?? min])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) value.value = val[0]
}
const toggleSlider = () => {
showSlider.value = !showSlider.value
}

View File

@@ -7,38 +7,81 @@ import { createI18n } from 'vue-i18n'
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
:aria-label="ariaLabel"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -2,34 +2,46 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ t('load3d.viewer.cameraType') }}</label>
<Select
v-model="cameraType"
:options="cameras"
option-label="title"
option-value="value"
>
<Select v-model="cameraType">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="cam in cameras"
:key="cam.value"
:value="cam.value"
>
{{ cam.title }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showFOVButton" class="flex flex-col gap-2">
<label>{{ t('load3d.fov') }}</label>
<Slider
v-model="fov"
:model-value="fovSliderValue"
:min="10"
:max="150"
:step="1"
:aria-label="t('load3d.fov')"
@update:model-value="onFovUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
@@ -41,4 +53,10 @@ const cameras = [
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const fovSliderValue = computed(() => [fov.value ?? 10])
function onFovUpdate(val: number[] | undefined) {
if (val?.length) fov.value = val[0]
}
</script>

View File

@@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n'
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
{{ opt[optionLabel] }}
</option>
</select>
`
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({

View File

@@ -1,11 +1,18 @@
<template>
<div class="space-y-4">
<Select
v-model="exportFormat"
:options="exportFormats"
option-label="label"
option-value="value"
>
<Select v-model="exportFormat">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in exportFormats"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
@@ -19,10 +26,14 @@
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
const emit = defineEmits<{
(e: 'exportModel', format: string): void

View File

@@ -17,19 +17,20 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
vi.mock('primevue/slider', () => ({
vi.mock('@/components/ui/slider/Slider.vue', () => ({
default: {
name: 'Slider',
name: 'UiSlider',
props: ['modelValue', 'min', 'max', 'step'],
emits: ['update:modelValue'],
template: `
<input
type="range"
:value="modelValue"
role="slider"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
:min="min"
:max="max"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
@input="$emit('update:modelValue', [Number($event.target.value)])"
/>
`
}

View File

@@ -3,18 +3,20 @@
<label>{{ $t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@update:model-value="onSliderUpdate"
/>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
@@ -28,4 +30,12 @@ const lightIntensityMinimum = useSettingStore().get(
const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderValue = computed(() => [
lightIntensity.value ?? lightIntensityMinimum
])
function onSliderUpdate(val: number[] | undefined) {
if (val?.length) lightIntensity.value = val[0]
}
</script>

View File

@@ -9,20 +9,65 @@ import type {
UpDirection
} from '@/extensions/core/load3d/interfaces'
vi.mock('primevue/select', () => ({
default: {
name: 'Select',
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
emits: ['update:modelValue'],
template: `
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
`
vi.mock('@/components/ui/select/Select.vue', async () => {
const { provide } = await import('vue')
return {
default: {
name: 'Select',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
props: { modelValue: string },
{ emit }: { emit: (event: string, value: string) => void }
) {
provide('selectModelValue', (): string => props.modelValue)
provide('selectUpdate', (v: string): void =>
emit('update:modelValue', v)
)
},
template: '<div><slot /></div>'
}
}
})
vi.mock('@/components/ui/select/SelectContent.vue', async () => {
const { inject, ref, onMounted } = await import('vue')
return {
default: {
name: 'SelectContent',
setup() {
const selectModelValue = inject<() => string>('selectModelValue')
const selectUpdate = inject<(v: string) => void>('selectUpdate')
const el = ref<HTMLSelectElement | null>(null)
onMounted(() => {
if (el.value) el.value.value = selectModelValue?.() ?? ''
})
return {
el,
onChange: (e: Event) => {
selectUpdate?.((e.target as HTMLSelectElement).value)
}
}
},
template: '<select ref="el" @change="onChange"><slot /></select>'
}
}
})
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: {
name: 'SelectItem',
props: ['value'],
template: '<option :value="value"><slot /></option>'
}
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<span />' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({

View File

@@ -2,31 +2,51 @@
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label>{{ $t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
option-label="label"
option-value="value"
/>
<Select v-model="upDirection">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in upDirectionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="materialModes.length > 0" class="flex flex-col gap-2">
<label>{{ $t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"
option-label="label"
option-value="value"
/>
<Select v-model="materialMode">
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in materialModeOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type {
MaterialMode,
UpDirection

View File

@@ -7,9 +7,15 @@
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
</div>
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
<div class="flex items-center gap-2">
<input
id="showGrid"
v-model="showGrid"
type="checkbox"
name="showGrid"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="showGrid" class="cursor-pointer">
{{ $t('load3d.showGrid') }}
</label>
</div>
@@ -58,7 +64,6 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { FOR_STORIES } from '@/components/ui/dialog/dialog.variants'
const { sizes } = FOR_STORIES
const meta: Meta = {
title: 'Components/Dialog/Dialog',
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: sizes,
defaultValue: 'md'
}
},
args: {
size: 'md'
}
}
export default meta
type Story = StoryObj
export const Default: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open dialog</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2">
<DialogDescription>
This action cannot be undone. The selected items will be permanently removed.
</DialogDescription>
</div>
<DialogFooter>
<Button variant="textonly" @click="open = false">Cancel</Button>
<Button variant="destructive" @click="open = false">Delete</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const LongContent: Story = {
render: (args) => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const open = ref(false)
return { args, open }
},
template: `
<Button @click="open = true">Open long content</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent :size="args.size">
<DialogHeader>
<DialogTitle>Long content scrolls</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 space-y-2 overflow-auto">
<p v-for="n in 30" :key="n">
Paragraph {{ n }} — the dialog body should scroll independently
while the header and footer stay pinned.
</p>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const Headless: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent
},
setup() {
const open = ref(false)
return { open }
},
template: `
<Button @click="open = true">Open headless</Button>
<Dialog v-model:open="open">
<DialogPortal>
<DialogOverlay />
<DialogContent size="sm" class="p-6">
<p class="text-sm">No header, no footer — fully custom content.</p>
<Button class="mt-4" @click="open = false">Close</Button>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: {
Button,
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose
},
setup() {
const openSize = ref<string | null>(null)
return { openSize, sizes }
},
template: `
<div class="flex gap-2 flex-wrap">
<Button v-for="s in sizes" :key="s" @click="openSize = s">{{ s }}</Button>
</div>
<Dialog
v-for="s in sizes"
:key="s"
:open="openSize === s"
@update:open="(o) => { if (!o) openSize = null }"
>
<DialogPortal>
<DialogOverlay />
<DialogContent :size="s">
<DialogHeader>
<DialogTitle>Size: {{ s }}</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="px-4 py-2 text-sm">
The {{ s }} size variant.
</div>
</DialogContent>
</DialogPortal>
</Dialog>
`
})
}

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emit = defineEmits<DialogRootEmits>()
</script>
<template>
<DialogRoot v-bind="props" @update:open="(open) => emit('update:open', open)">
<slot />
</DialogRoot>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { DialogClose } from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
</script>
<template>
<DialogClose as-child>
<slot>
<Button :aria-label="t('g.close')" size="icon" variant="muted-textonly">
<i class="icon-[lucide--x]" />
</Button>
</slot>
</DialogClose>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import { DialogContent, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { DialogContentSize } from './dialog.variants'
import { dialogContentVariants } from './dialog.variants'
const {
size,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
class?: HTMLAttributes['class']
}
>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(restProps, emits)
</script>
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size }), customClass)"
>
<slot />
</DialogContent>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import { DialogDescription } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogDescriptionProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<DialogDescription
v-bind="delegated"
:class="cn('text-sm text-muted-foreground', customClass)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-end gap-2 px-4 pt-2 pb-4',
customClass
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex shrink-0 items-center justify-between gap-2 px-4 pt-4 pb-2',
customClass
)
"
>
<slot />
</div>
</template>

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