Compare commits

...

158 Commits

Author SHA1 Message Date
DrJKL
1c48d41a88 fix: resolve lint errors and document describe naming convention
Amp-Thread-ID: https://ampcode.com/threads/T-019c4b83-5c21-714d-9c01-38e2e748c019
Co-authored-by: Amp <amp@ampcode.com>
2026-02-11 01:00:59 -08:00
DrJKL
2fc88abd59 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 22:43:11 -08:00
Alexander Brown
69062c6da1 deps: Update vite (#8509)
## Summary

Update from beta.8 to beta.12

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8509-deps-Update-vite-2f96d73d3650814c96dbe14cdfb02151)
by [Unito](https://www.unito.io)
2026-02-10 22:42:26 -08:00
DrJKL
b92fa9efe8 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 22:37:20 -08:00
Alexander Brown
a7c2115166 feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 19:37:17 -08:00
Csongor Czezar
d044bed9b2 feat: use object info display_name as fallback before node name (#7622)
## Description
Implements fallback to object info display_name before using internal
node name when display_name is unavailable.

## Related Issue
Related to backend PR: comfyanonymous/ComfyUI#11340

## Changes
- Modified `getNodeDefs()` to use object info `display_name` before
falling back to `name`
- Added unit tests for display name fallback behavior

## Testing
- All existing tests pass
- Added 4 new unit tests covering various display_name scenarios

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7622-W-I-P-feat-use-object-info-display_name-as-fallback-before-node-name-2cd6d73d365081deb22fe5ed00e6dc2e)
by [Unito](https://www.unito.io)
2026-02-10 22:18:48 -05:00
Comfy Org PR Bot
d873c8048f 1.40.0 (#8797)
Minor version increment to 1.40.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8797-1-40-0-3046d73d36508109b4b7ed3f6ca4530e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 19:06:48 -08:00
Comfy Org PR Bot
475d7035f7 1.39.12 (#8790)
Patch version increment to 1.39.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8790-1-39-12-3046d73d3650812faaf5dfaf71f6a02a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 18:52:06 -08:00
DrJKL
0a5af960d5 fix: replace props.* references with destructured prop variables
Amp-Thread-ID: https://ampcode.com/threads/T-019c4a8a-ada4-74ac-9496-ed1d43ee8ed2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 18:51:56 -08:00
guill
eb6bf91e20 fix(download): Use content-disposition filename (#8785)
When we download an output, we now check if there's a filename defined
in the content-disposition and use that if there is.

## Summary
This has been primarily an issue on Comfy Cloud where assets are
content-addressed. Before now,
the downloaded files have retained the hash as the filename. With this
change, downloaded files
will use the user-supplied filename instead.

## Changes

- **What**: Use content-disposition filename when downloading assets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8785-fix-download-Use-content-disposition-filename-3046d73d365081ec952ef3c1930e773d)
by [Unito](https://www.unito.io)
2026-02-10 18:50:42 -08:00
Alexander Brown
cdd8105b1a Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 18:48:33 -08:00
Csongor Czezar
422227d2fc fix: viewport overflow in manager (#7775)
### **Summary**
Fixes viewport overflow in version selector dropdown when Manager dialog
is positioned near edges

**Changes**
Removed popover arrow for visual consistency across all positions
Implemented dialog boundary detection to constrain popover within
Manager viewport

**Testing**
All existing unit tests pass (17/17)
Visually tested across different screen positions


![after-fix-viewport-all-positions](https://github.com/user-attachments/assets/287952f1-eda3-4388-9d6a-8f4316acea7f)

![before-fix-viewport-low](https://github.com/user-attachments/assets/b88dc61d-896b-48af-870f-2b5d52a11a98)

![before-fix-viewport-high](https://github.com/user-attachments/assets/7a39c845-0593-480e-843e-d5da30b48661)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7775-fix-viewport-overflow-in-manager-2d76d73d365081a88c1df2a103c5925e)
by [Unito](https://www.unito.io)
2026-02-10 21:29:44 -05:00
DrJKL
53013d04ef fix: use component __name for describe() labels in Vue component tests
Vue component objects render as [object Object] when passed directly
to describe(). Use Component.__name ?? 'Component' to produce readable
suite labels in test reporters.

Fixes vitest/prefer-describe-function-title for 69 component test files.

Amp-Thread-ID: https://ampcode.com/threads/T-019c4a6c-7593-710e-bf99-02821f6b76ba
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 18:19:49 -08:00
Terry Jia
10e9bc2f8d fix: extract WidgetCallbackOptions interface and add curly braces (#8791)
## Summary

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8774

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8791-fix-extract-WidgetCallbackOptions-interface-and-add-curly-braces-3046d73d365081c49e37c0a2596f1958)
by [Unito](https://www.unito.io)
2026-02-10 17:47:37 -08:00
Terry Jia
f7b835e6a5 fix: disable control after generate during partial execution (#8774)
## Summary
Passes an isPartialExecution flag through widget
beforeQueued/afterQueued callbacks so control-after-generate widgets
skip value modifications (randomize, increment, decrement) when the user
queues selected output nodes via partial execution.

requested by @christian-byrne in notion

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/3e723087-8849-457b-9f95-b8b5fceab0ed


after


https://github.com/user-attachments/assets/d9816667-51e0-4538-a012-9c84d0944019

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8774-fix-disable-control-after-generate-during-partial-execution-3036d73d365081688ca3d6b0506d69ca)
by [Unito](https://www.unito.io)
2026-02-10 20:13:03 -05:00
Johnpaul Chiwetelu
7f30d6b6a5 feat: add visual indicator for list output slots (#8766)
## Summary

Add rounded square dot shape and "(Iterative)" tooltip for list-type
output slots in Vue nodes, matching litegraph's visual indicator.

## Changes

- **What**: `SlotConnectionDot.vue` renders `rounded-[1px]` instead of
`rounded-full` when slot shape is `RenderShape.GRID`. `OutputSlot.vue`
appends "(Iterative)" to the tooltip for these slots.

<img width="807" height="542" alt="Screenshot 2026-02-10 at 03 38 42"
src="https://github.com/user-attachments/assets/137b60c5-ac3b-457f-a52d-58f5f28a59ea"
/>


## Review Focus
- i18n key added for the iterative suffix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8766-feat-add-visual-indicator-for-list-output-slots-3036d73d3650813aad85ce094d29c42b)
by [Unito](https://www.unito.io)
2026-02-11 01:49:58 +01:00
Benjamin Lu
da56c9e554 feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud
subscription checkout flows.

This PR adds Impact.com tracking support and carries attribution context
from landing-page visits into subscription checkout requests so
conversion attribution can be validated end-to-end.

- Register a new `ImpactTelemetryProvider` during cloud telemetry
initialization.
- Initialize the Impact queue/runtime (`ire`) and load the Universal
Tracking Tag script once.
- Invoke `ire('identify', ...)` on page views with dynamic `customerId`
and SHA-1 `customerEmail` (or empty strings when unknown).
- Expand checkout attribution capture to include `im_ref`, UTM fields,
and Google click IDs, with local persistence across navigation.
- Attempt `ire('generateClickId')` with a timeout and fall back to
URL/local attribution when unavailable.
- Include attribution payloads in checkout creation requests for both:
  - `/customers/cloud-subscription-checkout`
  - `/customers/cloud-subscription-checkout/{tier}`
- Extend begin-checkout telemetry metadata typing to include attribution
fields.
- Add focused unit coverage for provider behavior, attribution
persistence/fallback logic, and checkout request payloads.

Tradeoffs / constraints:
- Attribution collection is treated as best-effort in tiered checkout
flow to avoid blocking purchases.
- Backend checkout handlers must accept and process the additional JSON
attribution fields.

## Screenshots

<img width="908" height="208" alt="image"
src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/>
<img width="1144" height="460" alt="image"
src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/>
<img width="1432" height="320" alt="image"
src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/>
<img width="341" height="135" alt="image"
src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/>
2026-02-10 16:40:51 -08:00
Alexander Brown
79063edf54 Remove comfy logo splash screen. (#8786)
## Summary

```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8786-Remove-comfy-logo-splash-screen-3046d73d3650816d92c3ff04afeb8cf6)
by [Unito](https://www.unito.io)
2026-02-10 16:32:18 -08:00
Johnpaul Chiwetelu
d4c40f5255 fix: right-click context menu disabled when selection toolbox is off (#8781)
## Summary

- Move `NodeContextMenu` from `SelectionToolbox.vue` to
`GraphCanvas.vue` so the right-click context menu renders independently
of the `Comfy.Canvas.SelectionToolbox` setting

- Fixes #8417

## Test plan

- [x] Disable selection toolbox in settings, right-click a node —
context menu appears
- [x] Enable selection toolbox, right-click a node — context menu still
appears
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8781-fix-right-click-context-menu-disabled-when-selection-toolbox-is-off-3036d73d36508168a9add58e060b7e93)
by [Unito](https://www.unito.io)
2026-02-11 00:35:52 +01:00
Johnpaul Chiwetelu
1e1d5c8308 fix: stop suppressing link rendering during node resize (#8780)
## Summary

Stop link flickering when resizing nodes by removing the
`pendingSlotSync` flag assertion from `scheduleSlotLayoutSync`.

## Changes

- **What**: Remove `layoutStore.setPendingSlotSync(true)` from
`scheduleSlotLayoutSync()` in `useSlotElementTracking.ts`. This call was
introduced in #8367 for graph reconfiguration but was also triggered on
every node resize, causing all links to disappear for one frame per
resize tick. The reconfigure path in `app.ts`
(`addAfterConfigureHandler`) still sets the flag explicitly, so
undo/redo link suppression is unaffected.

## Review Focus

The `pendingSlotSync` flag is still managed correctly for graph
reconfiguration: `app.ts:748` sets it before configure, and the
`finally` block flushes it synchronously. The
`flushScheduledSlotLayoutSync` early-return (pendingNodes empty but
graph has nodes) continues to handle late-mounting Vue components during
reconfigure.

## Before

https://github.com/user-attachments/assets/28cfe4d8-f3f0-46f1-a717-5cb81a28dd75



## After




https://github.com/user-attachments/assets/9445fd00-91f8-4d1e-90ac-86d138d29842

Fixes #8696

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8780-fix-stop-suppressing-link-rendering-during-node-resize-3036d73d365081029820ccfd57425a07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-11 00:03:40 +01:00
Alexander Brown
18f3877cab fix: resolve new oxlint violations after upgrade
- Fix import ordering (absolute before relative imports)

- Fix no-immediate-mutation (Set.add/push/Object.assign after init)

- Migrate describe.each to describe.for

- Hoist vi.mock to top level in firebaseAuthStore test

- Fix unsafe optional chaining in subgraphStore

- Fix acceptTypes computed returning null instead of undefined

- Configure vue/return-in-computed-property treatUndefinedAsUnspecified

- Add oxlint-disable for incorrect prefer-describe-function-title auto-fixes

Amp-Thread-ID: https://ampcode.com/threads/T-019c495f-2269-701f-9a3f-c6fe378804ba
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 14:52:12 -08:00
Johnpaul Chiwetelu
e411a104f4 feat: scroll to specific setting when opening settings dialog (#8761)
## Summary

- Adds `settingId` parameter to `showSettingsDialog` that auto-navigates
to the correct category tab, scrolls to the setting, and briefly
highlights it with a CSS pulse animation
- Adds `data-setting-id` attributes to setting items for stable DOM
targeting
- Adds "Don't show this again" checkbox with "Re-enable in Settings"
deep-link to the missing nodes dialog
- Adds "Re-enable in Settings" deep-link to missing models and blueprint
overwrite "Don't show this again" checkboxes

- Fixes #3437

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass (59/59 including 5 new tests for `useSettingUI`)



https://github.com/user-attachments/assets/a9e80aea-7b69-4686-b030-55a2e0570ff0



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8761-feat-scroll-to-specific-setting-when-opening-settings-dialog-3036d73d365081d18d9afe9f9ed41ebc)
by [Unito](https://www.unito.io)
2026-02-10 23:00:46 +01:00
Terry Jia
19a724710c fix: address review nits in load3d (#8779)
## Summary
- Refactor getModelUrl to use const instead of let
- add missing language key

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8765

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8779-fix-address-review-nits-in-load3d-3036d73d36508183af11c5e9bc545650)
by [Unito](https://www.unito.io)
2026-02-10 16:09:54 -05:00
Alexander Brown
21445f1faf deps: Update oxfmt and oxlint 2026-02-10 13:01:38 -08:00
Alexander Brown
6a1dcf8a1e Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 12:46:06 -08:00
Terry Jia
9ecbb3af27 Feat/3d dropdown (#8765)
## Summary
Add mesh_upload and upload_subfolder to combo input schema so
WidgetSelect detects mesh uploads generically instead of hardcoding node
type checks. Inject these flags in load3dLazy.ts so they are available
before THREE.js loads.

Also unify SUPPORTED_EXTENSIONS_ACCEPT across load3d and dropdown, pass
uploadSubfolder prop through to WidgetSelectDropdown for correct upload
path, and update error message to list all supported extensions.

replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/7975

(We should include thumbnail but not yet, will do it later)

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2cb4b1da-af4f-439b-9786-3ac780c2480d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8765-Feat-3d-dropdown-3036d73d365081d8a10ee19d3ed7d295)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2026-02-10 15:36:57 -05:00
Alexander Brown
a13d28cc16 Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 12:34:58 -08:00
AustinMroz
581452d312 Austin/fix move subgraph input (#8777)
Previously, moving a subgraph input link and re-attaching to the same
input slot would result in an invalid link


![broken-link](https://github.com/user-attachments/assets/085a0a6f-281d-4e06-be58-e5bdc873f1d5)

This occurred because:
- A new link is created to which overwrites the target `input.link`
- The previous link is then disconnected, which clears `input.link`

This is solved by instead returning early if the target is the same as
the existing link.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8777-Austin-fix-move-subgraph-input-3036d73d365081318de3cccb926f7fe7)
by [Unito](https://www.unito.io)
2026-02-10 12:31:09 -08:00
Simula_r
9dde4e7bc7 feat: sort workspaces (#8770)
## Summary

Sort workspaces so that the personal workspace appears first, followed
by the rest in ascending order (oldest first) by created_at / joined_at.

## Changes

- **What**: teamWorkspaceStore.ts, teamWorkspaceStore.test.ts
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->
2026-02-10 10:11:35 -08:00
Johnpaul Chiwetelu
0288ea5b39 feat: add setMany to settingStore for batch setting updates (#8767)
## Summary
- Adds `setMany()` method to `settingStore` for updating multiple
settings in a single API call via the existing `storeSettings` endpoint
- Extracts shared setting-apply logic (`applySettingLocally`) to reduce
duplication between `set()` and `setMany()`
- Migrates all call sites where multiple settings were updated
sequentially to use `setMany()`

## Call sites updated
- `releaseStore.ts` — `handleSkipRelease`, `handleShowChangelog`,
`handleWhatsNewSeen` (3 settings each)
- `keybindingService.ts` — `persistUserKeybindings` (2 settings)
- `coreSettings.ts` — `NavigationMode.onChange` (2 settings)

## Test plan
- [x] Unit tests for `setMany` (batch update, skip unchanged, no-op when
unchanged)
- [x] Updated `releaseStore.test.ts` assertions to verify `setMany`
usage
- [x] Updated `useCoreCommands.test.ts` mock to include `setMany`
- [x] All existing tests pass
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm format` pass

Fixes #1079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8767-feat-add-setMany-to-settingStore-for-batch-setting-updates-3036d73d36508161b8b6d298e1be1b7a)
by [Unito](https://www.unito.io)
2026-02-10 13:47:53 +01:00
Comfy Org PR Bot
061e96e488 1.39.11 (#8763)
Patch version increment to 1.39.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8763-1-39-11-3036d73d365081458389fe558cd921ee)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-09 21:28:11 -08:00
Alexander Brown
ff9642d0cb feat: deduplicate subgraph node IDs on workflow load (experimental) (#8762)
## Summary

Add `ensureGlobalIdUniqueness` to reassign duplicate node IDs across
subgraphs when loading workflows, gated behind an experimental setting.

## Changes

- **What**: Shared `LGraphState` between root graph and subgraphs so ID
counters are global. Added `ensureGlobalIdUniqueness()` method that
detects and remaps colliding node IDs in subgraphs, preserving root
graph IDs as canonical and patching link references. Gated behind
`Comfy.Graph.DeduplicateSubgraphNodeIds` (experimental, default
`false`).
- **Dependencies**: None

## Review Focus

- Shared state override on `Subgraph` (getter delegates to root, setter
is no-op) — verify no existing code sets `subgraph.state` directly.
- `Math.max` state merging in `configure()` prevents ID counter
regression when loading subgraph definitions.
- Feature flag wiring: static property on `LGraph`, synced from settings
via `useLitegraphSettings`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8762-feat-deduplicate-subgraph-node-IDs-on-workflow-load-experimental-3036d73d36508184b6cee5876dc4d935)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-09 18:01:58 -08:00
AustinMroz
a6620a4ddc Fix edge cases in subgraph removal logic (#8758)
#8187 made removal of subgraphs cleanup the subgraph definition for the
removed graph and call onRemove handlers. However, it missed some edge
cases and broke subgraph conversion of selections containing subgraphs
which this PR tries to address.
- Deeply nested subgraphs are now also cleaned
- Adding a subgraphNode to the graph also ensures nested subgraphs are
added to subgraph definitions

Reminder: under this change, nodes can continue to exist after their
onRemoved handler has been called
- It may be better to instead perform the "garbage collection" of
subgraphs outside of the graph removal step to better handle edge cases
like subgraph conversion where a subgraph may continue to persist after
a parent subgraphNode has been removed from a graph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8758-Fix-edge-cases-in-subgraph-removal-logic-3026d73d36508177b34ffdd2e0a114fe)
by [Unito](https://www.unito.io)
2026-02-09 13:55:23 -08:00
Benjamin Lu
9209badd37 feat: add KSampler live previews to assets sidebar jobs (#8723)
## Summary
Show live KSampler previews on active job cards/list items in the Assets
sidebar, while preserving existing fallback behavior.

## Changes
- **What**:
- Added a prompt-scoped job preview store (`jobPreviewStore`) gated by
`Comfy.Execution.PreviewMethod`.
- Wired `b_preview_with_metadata` handling to map previews by
`promptId`.
- Extended queue job view model with `livePreviewUrl` and consumed it in
both sidebar list and grid active job UIs.
  - Cleared prompt previews on execution reset.
- Added ref-counted shared blob URL lifecycle utility (`objectUrlUtil`)
and updated preview stores to retain/release shared URLs so each preview
event creates one object URL.
- Added/updated unit coverage in `useJobList.test.ts` for preview
enable/disable mapping.

## Review Focus
- Object URL lifecycle correctness across node previews and job previews
(retain/release behavior).
- Preview gating behavior when `Comfy.Execution.PreviewMethod` is
`none`.
- Active job UI fallback behavior (`livePreviewUrl` -> `iconImageUrl`).

## Screenshots (if applicable)
<img width="808" height="614" alt="image"
src="https://github.com/user-attachments/assets/37c66eb2-8c28-4eb4-bb86-5679cb77d740"
/>
<img width="775" height="345" alt="image"
src="https://github.com/user-attachments/assets/aa420642-b0d4-4ae6-b94a-e7934b5df9d6"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8723-feat-add-KSampler-live-previews-to-assets-sidebar-jobs-3006d73d365081aeb81dd8279bf99f94)
by [Unito](https://www.unito.io)
2026-02-09 10:49:27 -08:00
Benjamin Lu
815be49112 fix: keep begin_checkout user_id reactive in subscription flows (#8726)
## Summary

Use reactive `userId` reads for `begin_checkout` telemetry so delayed
auth state updates are reflected at event time instead of using a stale
snapshot.

## Changes

- **What**: switched subscription checkout telemetry paths to
`storeToRefs(useFirebaseAuthStore())` and read `userId.value` when
dispatching `trackBeginCheckout`.
- **What**: added regression tests that mutate `userId` after setup /
after checkout starts and assert telemetry uses the updated ID.

## Review Focus

- Verify `PricingTable` and `performSubscriptionCheckout` still emit
exactly one `begin_checkout` event per action, with `checkout_type:
change` and `checkout_type: new` in their respective paths.
- Verify the new tests would fail with stale store destructuring
(manually validated during development).

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8726-fix-keep-begin_checkout-user_id-reactive-in-subscription-flows-3006d73d365081888c84c0335ab52e09)
by [Unito](https://www.unito.io)
2026-02-09 02:01:23 -08:00
Alexander Brown
2ed5618331 fix: move import above vi.mock() calls to satisfy import/first rule
Amp-Thread-ID: https://ampcode.com/threads/T-019c4174-f519-717b-9274-b17c24711353
Co-authored-by: Amp <amp@ampcode.com>
2026-02-09 00:16:35 -08:00
Alexander Brown
c4c65070e9 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-09 00:13:16 -08:00
Hunter
adbfb83767 feat: wire renewal_date from cloud billing status (#8754)
## Summary

Wire `renewal_date` from the cloud `/billing/status` response into the
workspace subscription UI so users can see when their subscription
renews.

## Problem

The workspace billing adapter hardcoded `renewalDate: null` because the
cloud billing status endpoint didn't return a renewal date. The
`SubscriptionPanelContentWorkspace` component already has UI for
displaying it — it just had no data.

Personal Workspace (existing `cloud-subscription-status`):
<img width="181" height="112" alt="Screenshot 2026-02-08 at 7 54 53 PM"
src="https://github.com/user-attachments/assets/a96ba2cd-10d0-442a-ae72-dbc663a9e52b"
/>

Current missing data from `/billing/status`:
<img width="240" height="124" alt="Screenshot 2026-02-08 at 7 55 38 PM"
src="https://github.com/user-attachments/assets/a3f51ce3-6663-43e1-97ed-d012a6a8a5ba"
/>

## Solution

- Add `renewal_date?: string` to `BillingStatusResponse` interface
- Use `status.renewal_date ?? null` instead of hardcoded `null` in
`useWorkspaceBilling`

### Related
- Cloud PR: Comfy-Org/cloud#2370 (adds `renewal_date` to the endpoint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8754-feat-wire-renewal_date-from-cloud-billing-status-3026d73d365081c7ae51d79ef0633a1d)
by [Unito](https://www.unito.io)
2026-02-08 23:49:20 -08:00
Terry Jia
3238ad3d32 fix: re-mount DOM widget elements after leaving Linear mode (#8753)
## Summary

LinearView renders its own WidgetDOM instances which steal the
widget.element via replaceChildren. When LinearView unmounts
(v-if="linearMode") the element is removed from the DOM entirely.
The canvas-side WidgetDOM stays mounted but its container is now empty,
so DOM widgets (e.g. Three.js scenes) disappear.

Watch canvasStore.linearMode and reclaim the element when switching back
from Linear to Canvas mode.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/78cea2bc-c4b3-4b21-bdb3-a521bb0d3062


after


https://github.com/user-attachments/assets/8f92c44d-9514-4001-bbdb-bc4c80468ed7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8753-fix-re-mount-DOM-widget-elements-after-leaving-Linear-mode-3026d73d3650810b8968eff13dc84e9a)
by [Unito](https://www.unito.io)
2026-02-08 22:51:57 -05:00
Comfy Org PR Bot
be515d6fcc 1.39.10 (#8752)
Patch version increment to 1.39.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8752-1-39-10-3026d73d3650816ebe1edb895feb37a1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-08 17:30:05 -08:00
Christian Byrne
b583c92c64 fix: only show Failed Tests section when there are actual failures (#8575)
## Description

The Playwright test comment was showing a " Failed Tests" section
header even when there were only flaky tests (no actual failures). This
was confusing because the red X suggested failure when tests actually
passed.

**Before:** Shows " Failed Tests" section for flaky-only runs
**After:** Only shows " Failed Tests" section when there are actual
failures; flaky tests are treated as passing

## Related Issue

Fixes the misleading comment behavior seen in PR #8573



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8575-fix-only-show-Failed-Tests-section-when-there-are-actual-failures-2fc6d73d36508167889cc252e4e06f2e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-08 13:14:38 -08:00
Christian Byrne
c0a209226d fix: handle RIFF padding for odd-sized WEBP chunks (#8527)
## Summary

Fix WEBP workflow loading failures for files with odd-sized chunks
before the EXIF chunk.

## Problem

WEBP files use the RIFF container format which [requires odd-sized
chunks to be padded with a single
byte](https://developers.google.com/speed/webp/docs/riff_container#riff_file_format):

> If Chunk Size is odd, a single padding byte -- which MUST be 0 to
conform with RIFF -- is added.

The `getWebpMetadata` function was not accounting for this padding,
causing it to miss the EXIF chunk in files with odd-sized preceding
chunks. This resulted in "Unable to find workflow in [filename].webp"
errors for valid WEBP files.

## Solution

Add the padding byte to the offset calculation:
```typescript
offset += 8 + chunk_length + (chunk_length % 2)
```

## Testing

- Tested with the sample image provided in the issue which previously
failed to load

Fixes #8076

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8527-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-2fa6d73d3650815fb849fb6a4e767162)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-08 12:20:31 -08:00
Christian Byrne
c91d811d00 feat(cloud): add asset widget support for PrimitiveNode model selection (#8598)
Add cloud asset widget creation in `_createWidget()` using
`isAssetBrowserEligible()`
- Extract shared `createAssetWidget` factory to
`src/platform/assets/utils/`
- Refactor `useComboWidget.ts` to use the shared factory
- Add `_finalizeWidget()` helper to DRY up widget sizing/callback setup
- Pass target node's `comfyClass` and input name to Asset Browser for
correct model filtering
- Check `Comfy.Assets.UseAssetAPI` setting (matches `useComboWidget.ts`
behavior)
- Sync existing target widget value to asset widget
- Add toast notifications for asset validation errors
- Add i18n translations for invalidAsset and invalidFilename errors

Supersedes #8461 (clean rebase, no merge commits)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8598-feat-cloud-add-asset-widget-support-for-PrimitiveNode-model-selection-2fd6d73d365081a8afa7c2e91762f11c)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced asset widget integration for cloud-based model selection,
enabling users to browse and select assets through an improved
interface.
* Added comprehensive asset validation with enhanced error messages for
invalid assets and filenames.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: guill <jacob.e.segal@gmail.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-08 12:18:13 -08:00
Alexander Brown
7ad917343e Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 22:36:21 -08:00
Alexander Brown
e625b0351c feat: migrate from @iconify/tailwind to @iconify/tailwind4 (#8724)
## Summary

Migrate from `@iconify/tailwind` (Tailwind 3 JS plugin) to
`@iconify/tailwind4` (native Tailwind 4 CSS plugin), moving all config
into CSS directives.

## Changes

- **What**: Replace `addDynamicIconSelectors()` JS plugin with `@plugin
"@iconify/tailwind4"` CSS directive. Move `boxShadow` theme extension
into `@theme` block. Delete both `tailwind.config.ts` files and the
runtime `iconCollection.ts` module.
- **Dependencies**: `@iconify/tailwind` removed, `@iconify/tailwind4`
added

## Review Focus

- `from-folder` path resolution in monorepo context (paths relative to
project root)
- SVG auto-cleanup behavior of `from-folder` vs the previous manual
`iconCollection.ts` loader
- Removal of `@config` directive and both tailwind config files — all
config now in CSS

## Files

| File | Change |
|------|--------|
| `pnpm-workspace.yaml` | Swap catalog entry |
| `packages/design-system/package.json` | Swap dep, remove
`tailwind-config` export |
| `packages/design-system/src/css/style.css` | Add `@plugin`,
`--shadow-interface` theme token, remove `@config` |
| `packages/design-system/tailwind.config.ts` | Deleted |
| `packages/design-system/src/iconCollection.ts` | Deleted |
| `tailwind.config.ts` | Deleted |
| `tsconfig.json`, `components.json` | Remove stale references |
| `knip.config.ts` | Ignore `@iconify-json/lucide` (CSS-consumed, not
JS-imported) |
| Docs | Updated `CONTRIBUTING.md` and `icons/README.md` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8724-feat-migrate-from-iconify-tailwind-to-iconify-tailwind4-3006d73d36508144a9b3e7ae73448f98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-07 22:35:34 -08:00
Benjamin Lu
a56f2d3883 fix: stabilize flaky workflows save-as browser assertions (#8735)
## Summary

Stabilize flaky workflows sidebar browser tests by waiting for eventual
UI state after `Save As`/overwrite operations.

## Changes

- **What**: Replace immediate assertions with retrying
`expect.poll(...)` in `browser_tests/tests/sidebar/workflows.spec.ts`
for:
  - `Can save workflow as`
  - `Can overwrite other workflows with save as`

## Review Focus

Verify the polling assertions are scoped to the intended eventual UI
state and do not hide real regressions.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8735-fix-stabilize-flaky-workflows-save-as-browser-assertions-3016d73d3650814abad1d767c0910ef6)
by [Unito](https://www.unito.io)
2026-02-07 22:35:14 -08:00
Alexander Brown
2eb7b8c994 Add @Comfy-org/comfy_frontend_devs to CODEOWNERS (#8739)
Updated CODEOWNERS file to include @Comfy-org/comfy_frontend_devs as an
owner for multiple paths.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
2026-02-07 20:52:46 -08:00
Christian Byrne
3eccf3ec61 feat: default to Getting Started category for new users in templates modal (#8599)
## Summary

Updates the templates modal to default to the "Getting Started" category
for new users.

## Changes

- Add `initialCategory` prop to `WorkflowTemplateSelectorDialog`
component
- Integrate `useNewUserService` in the dialog composable to detect
first-time users
- New users automatically see the "basics-getting-started" category
- Existing users continue to see "all" templates as default
- Allow explicit category override via options parameter

## Testing

- Added unit tests covering all scenarios (new user, non-new user,
undetermined, explicit override)
- 6 tests pass

Fixes COM-9146

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8599-feat-default-to-Getting-Started-category-for-new-users-in-templates-modal-2fd6d73d365081d4a5fad2abdb768269)
by [Unito](https://www.unito.io)
2026-02-07 20:30:10 -08:00
Hunter
1b73b5b31e fix: show credit balance for unsubscribed personal workspaces (#8719)
## Summary

Credit balance was not displayed in the user popover for personal
workspace users without an active subscription. The `displayedCredits`
computed returned `"0"` and `refreshBalance` skipped the API call when
there was no active subscription, hiding any existing balance.

## Changes

- **What**: Remove subscription-gated guards in
`CurrentUserPopoverWorkspace.vue`:
- `displayedCredits`: no longer returns early `""` / `"0"` when
subscription is null or inactive — always reads from the balance API
response
- `refreshBalance`: always fetches balance on popover open regardless of
subscription status

## Review Focus

The credits section visibility is already gated by `showCreditsSection`
(personal workspace or owner role). This change only affects what value
is displayed and whether the balance API is called — it does not change
who sees the section.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8719-fix-show-credit-balance-for-unsubscribed-personal-workspaces-3006d73d3650812e9d70e5a8629c5f60)
by [Unito](https://www.unito.io)
2026-02-07 20:22:00 -08:00
Comfy Org PR Bot
882d595d4a 1.39.9 (#8707)
Patch version increment to 1.39.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-07 20:17:39 -08:00
Alexander Brown
10845cb865 Revert "fix: add post-processing script to fix i18n nodeDefs array corruption" (#8736)
Reverts Comfy-Org/ComfyUI_frontend#8718
2026-02-07 20:08:02 -08:00
Alexander Brown
25cc481e08 Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 19:50:46 -08:00
Christian Byrne
6c8473e4e4 refactor: replace runtime isElectron() with build-time isDesktop constant (#8710)
## Summary

Replace all runtime `isElectron()` function calls with the build-time
`isDesktop` constant from `@/platform/distribution/types`, enabling
dead-code elimination in non-desktop builds.

## Changes

- **What**: Migrate 30 files from runtime `isElectron()` detection
(checking `window.electronAPI`) to the compile-time `isDesktop` constant
(driven by `__DISTRIBUTION__` Vite define). Remove `isElectron` from
`envUtil.ts`. Update `isNativeWindow()` to use `isDesktop`. Guard
`electronAPI()` calls behind `isDesktop` checks in stores. Update 7 test
files to use `vi.hoisted` + getter mock pattern for per-test `isDesktop`
toggling. Add `DISTRIBUTION=desktop` to `dev:electron` script.

## Review Focus

- The `electronDownloadStore.ts` now guards the top-level
`electronAPI()` call behind `isDesktop` to prevent crashes on
non-desktop builds.
- Test mocking pattern uses `vi.hoisted` with a getter to allow per-test
toggling of the `isDesktop` value.
- Pre-existing issues not addressed: `as ElectronAPI` cast in
`envUtil.ts`, `:class="[]"` in `BaseViewTemplate.vue`,
`@ts-expect-error` in `ModelLibrarySidebarTab.vue`.
- This subsumes PR #8627 and renders PR #6122 and PR #7374 obsolete.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8710-refactor-replace-runtime-isElectron-with-build-time-isDesktop-constant-3006d73d365081c08037f0e61c2f6c77)
by [Unito](https://www.unito.io)
2026-02-07 19:47:05 -08:00
Benjamin Lu
b7fef1c744 fix: update queue tooltip copy to include right-click hint (#8733)
### Motivation
- Update the run-menu queue tooltip to include the
right-click-to-clear-queue hint so the UI copy matches the requested
product wording.

### Description
- Replaced `sideToolbar.queueProgressOverlay.viewJobHistory` value in
`src/locales/en/main.json` with `View active jobs (right-click to clear
queue)`.

### Testing
- Ran `pnpm lint` and `pnpm typecheck`, and both completed successfully.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_6987ee702bdc8330ad0d58f0b014c262)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8733-fix-update-queue-tooltip-copy-to-include-right-click-hint-3016d73d365081c09fa7f582ee51c6c2)
by [Unito](https://www.unito.io)
2026-02-07 19:46:23 -08:00
Alexander Brown
8ff385fc4d feat: wrap async Preview3d component in Suspense boundary
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b5a-1924-741a-9970-e14ef828eb46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:45:46 -08:00
Alexander Brown
6ae2bc0e2a fix: handle floating promise in DropZone onDragDrop call
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b58-626e-743f-8d69-5c4ff6bac504
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:44:11 -08:00
Alexander Brown
ae8940c0c0 fix: use defineAsyncComponent for Preview3d, clean up RAF state in minimap tests
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b52-524e-770c-9752-1bcf3f5c6388
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:36:54 -08:00
Alexander Brown
6465c48423 fix: forward class prop via cn() in Select and SelectValue wrappers
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b4d-3855-7669-877f-fc96de0f89b6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:32:21 -08:00
Alexander Brown
6db94117b8 fix: misc cleanups and small fixes
- Remove duplicate console.error in TopMenuSection
- Use cn() for class merging in NoResultsPlaceholder
- Refactor UrlInput to use defineModel instead of manual v-model
- Use getElementById instead of querySelector for ID lookup in LGraphCanvas
- Fix LiteGraphGlobal.registerNodeType category when type has no slash
- Replace null with undefined in LGraphNode test mock computeds

Amp-Thread-ID: https://ampcode.com/threads/T-019c3b43-b81f-7028-b3d4-be4e08d63238
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:23:20 -08:00
Alexander Brown
58d82b789b fix: use Vue useId() instead of Math.random() for NodeSearchBox inputId
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b39-c0e9-7116-9e25-0381624c4b82
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:10:23 -08:00
Alexander Brown
da43303ecf Fix merge issue. 2026-02-07 18:44:57 -08:00
Alexander Brown
e00e2848a8 fix: exclude JSONC files from strict JSON validation
.oxlintrc.json uses JSONC (comments), which jq cannot parse.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3a27-819d-712a-8762-03ee5eb6e76c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 18:37:41 -08:00
Alexander Brown
f2d5c415ae Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 18:32:29 -08:00
Terry Jia
828323e263 fix: add post-processing script to fix i18n nodeDefs array corruption (#8718)
## Summary
@lobehub/i18n-cli (GPT-4.1) converts numeric-keyed objects like {"0":
{...}, "1": {...}} into JSON arrays with null gaps, which
crashes vue-i18n path resolution. 

Add a post-processing step that converts arrays back to objects after
translation.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/44e81790-feae-405b-b2c4-098b06a98785


after


https://github.com/user-attachments/assets/5d1bd836-c923-437a-aca0-7ebd4d8acb89
<img width="2703" height="1083" alt="image"
src="https://github.com/user-attachments/assets/370a2eb0-c46d-4901-a23a-ab3002a9660d"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8718-fix-add-post-processing-script-to-fix-i18n-nodeDefs-array-corruption-3006d73d365081dab020fea997ec4c0a)
by [Unito](https://www.unito.io)
2026-02-07 18:19:37 -08:00
Christian Byrne
6535138e0b fix(vue-nodes): hide slot labels for reroute nodes with empty names (#8574)
## Summary
Fixes reroute node styling in Vue Nodes 2.0 by hiding slot labels when
slot names are intentionally empty.


| Before | After |
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
| <img width="1437" height="473" alt="image"
src="https://github.com/user-attachments/assets/603f52e0-7b75-4822-8c91-0a8374cc0cb6"
/> | <img width="1350" height="493" alt="image"
src="https://github.com/user-attachments/assets/38168955-4d35-4c61-a685-a54efb44cd5d"
/> |


## Problem
Reroute nodes displayed unwanted fallback labels ("Input 0", "Output 0")
instead of appearing as minimal connection-only nodes. This happened
because:
- Reroute nodes intentionally use empty string (`""`) for slot names
- Slot components used `||` operator for fallback labels, treating `''`
as falsy

## Solution
- Add `hasNoLabel` computed property to detect when all label sources
(`label`, `localized_name`, `name`) are empty/falsy
- Derive `dotOnly` from either the existing prop OR `hasNoLabel` being
true
- When `dotOnly` is true: label text is hidden, padding removed
(`lg-slot--dot-only` class), only connection dot visible

Combined with existing `NO_TITLE` support from #7589, reroutes now
display as minimal nodes with just connection dots—matching classic
reroute appearance.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
* Enhanced input and output slot label handling to automatically conceal
labels when unavailable
* Improved fallback display names for slots with more reliable naming
logic

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8574-fix-vue-nodes-hide-slot-labels-for-reroute-nodes-with-empty-names-2fc6d73d365081c38031e260402283d3)
by [Unito](https://www.unito.io)
2026-02-07 15:30:20 -08:00
Alexander Brown
0edd5b17e7 Merge issues 2026-02-07 14:11:53 -08:00
Alexander Brown
8376db4813 fix: use oxlint-disable-next-line instead of eslint-disable-next-line
Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:34 -08:00
Alexander Brown
d9e9d68230 chore: disable high-violation rules pending incremental cleanup
7 rules set to off with TODO comments noting violation counts:

- no-param-reassign (104), prefer-destructuring (581)

- promise/prefer-await-to-callbacks (76), promise/prefer-await-to-then (91)

- unicorn/consistent-function-scoping (147), unicorn/no-array-for-each (165)

- typescript/prefer-nullish-coalescing (372)

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:31 -08:00
Alexander Brown
06b96b13b9 chore: promote 0-violation guardrail rules from warn to error
vitest/prefer-describe-function-title, unicorn/no-immediate-mutation,

promise/no-nesting, typescript/prefer-optional-chain

All had 0 violations. vitest/warn-todo kept as warn (intentional annotation).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:27 -08:00
Alexander Brown
05c5d08acb chore: enable oxlint rule promise/prefer-await-to-callbacks
76 warnings across many files. Prefer async/await over callback patterns.

Too many violations for one pass; kept as warn for incremental cleanup.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:24 -08:00
Alexander Brown
0aec287e83 chore: enable oxlint rule promise/prefer-await-to-then
91 warnings across the codebase. Kept as warn for incremental cleanup — .then() usage is widespread. Configured with strict: false.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39dc-eeba-709e-9885-eb3d0a605157
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:21 -08:00
Alexander Brown
a2fdb2fcb2 chore: enable oxlint rule eslint/prefer-destructuring
Amp-Thread-ID: https://ampcode.com/threads/T-019c39d8-20d7-71d2-9feb-52961de2c1f0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:17 -08:00
Alexander Brown
20b16009c7 chore: enable oxlint rule eslint/no-param-reassign
Enabled as warn. 104 warnings — too many to fix in one pass.

Disallows reassigning function parameters. Aligns with AGENTS.md immutability preference.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39d6-cc76-7120-b787-8be5974594c2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:15 -08:00
Alexander Brown
f13a88a64e chore: enable oxlint rule eslint/func-style
16 violations fixed: converted const fn = function() to function declarations (or arrow functions where type narrowing required it). Enabled as error.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39cd-c141-776b-9285-eabd3d9ffacd
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:11 -08:00
Alexander Brown
31a1e14d2c chore: enable oxlint rule unicorn/prefer-set-has
5 violations fixed across 3 files — converted array+includes to Set+has for constant membership-check collections. All violations were small constant arrays used only for .includes() lookups, safe to convert.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39c6-819f-77ef-809f-4a55da6b327c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:07 -08:00
Alexander Brown
924db2b815 chore: enable oxlint rule unicorn/no-array-for-each
Enabled as warn with 165 violations. Enforces for...of over .forEach(). Too many violations for one pass; kept as warn for incremental cleanup. No fixes applied.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39c4-ce2d-7532-b4d9-454b7220e93f
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:02 -08:00
Alexander Brown
e0090a5a4b chore: enable oxlint rule unicorn/consistent-function-scoping
147 warnings across many files. Configured with checkArrowFunctions: true.
Kept as warn due to high violation count (50+ threshold).
Enforces moving functions that don't capture outer scope to module level.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39bf-3aef-723e-8f3a-d4c5c8648cff
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:57 -08:00
Alexander Brown
620ad24796 chore: enable oxlint rule typescript/prefer-optional-chain
0 violations found — guardrail rule enforcing foo?.bar over foo && foo.bar. Enabled as warn due to dangerous auto-fix semantics (optional chaining may change return types).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39ba-188b-711c-a738-dcefc8c37a37
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:56 -08:00
Alexander Brown
ea0e6b9d2a chore: enable oxlint rule typescript/prefer-nullish-coalescing
372 warnings (0 errors). Enabled as warn with ignoreConditionalTests.

Too many violations for one pass - kept as warn for incremental cleanup.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39b4-0744-73d6-925e-1ede662289f9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:54 -08:00
Alexander Brown
fd78ec3077 chore: enable oxlint rule promise/param-names
38 violations fixed across 5 files. All were shorthand Promise param names (r, resolveFn/rejectFn, _) renamed to resolve/reject/_resolve/_reject.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39af-8fe0-73ad-a7fd-1373cc0380db
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:52 -08:00
Alexander Brown
d02dfca1de chore: enable oxlint rule promise/prefer-catch
0 violations found — guardrail rule enforcing .catch(fn) over .then(null, fn) or .then(a, b).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39aa-ceeb-723e-9660-29c0178b9e45
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:50 -08:00
Alexander Brown
81606328b8 chore: enable oxlint rule promise/no-nesting
Add promise plugin to oxlint config. Enable promise/no-nesting as warn.

0 no-nesting violations found. 1 auto-enabled no-callback-in-promise false positive fixed by renaming callback parameter in useMinimap.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39a3-7c77-75a0-9e4d-9e1310d099aa
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:47 -08:00
Alexander Brown
ee1d61b71b chore: enable oxlint rule prefer-template
61 violations across 39 files converted from string concatenation to template literals. Auto-fix listed as planned but not implemented — all fixes applied manually.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3992-c9b1-753d-8f16-4712af1f1ee8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:43 -08:00
Alexander Brown
f7b50067a6 chore: enable oxlint rule import/first
99 violations fixed across 35 files. Reordered imports so absolute imports (packages, @/ aliases) come before relative imports (./, ../). Configured with absolute-first option.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3986-6dde-744d-84bf-8a7e445b9bf7
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:34 -08:00
Alexander Brown
00aa420049 chore: enable oxlint rule eslint/no-else-return
29 violations fixed across 25 files.
Configured with allowElseIf: false to also flatten else-if chains after returns.
19 auto-fixed by oxlint, 10 manually fixed (cascaded else-if/else chains).
Fixed broken auto-fix output in useConflictDetection.ts (dangling code block).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3978-5586-76db-9c5b-25ca30371483
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:08 -08:00
Alexander Brown
eb883c507d chore: enable oxlint rule unicorn/prefer-array-find
0 violations found - guardrail rule enforcing .find() over .filter()[0]

Amp-Thread-ID: https://ampcode.com/threads/T-019c3973-c4a9-755c-9d6d-2a1ba5b448c6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:05 -08:00
Alexander Brown
4aa40a560c chore: enable oxlint rule unicorn/prefer-add-event-listener
14 violations across 6 files. Converted .onX= assignments to addEventListener().

In useNodeFileInput.ts, extracted handler to named function for proper removeEventListener cleanup.

In litegraphService.ts, introduced const img to allow TypeScript narrowing inside Promise closure, removing 3 @ts-expect-error comments.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38c9-c4b0-71f7-a19f-9b9c339bb99d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:03 -08:00
Alexander Brown
f998fc0aab chore: enable oxlint rule unicorn/no-immediate-mutation
Severity: warn (guardrail). 0 violations found.
Amp-Thread-ID: https://ampcode.com/threads/T-019c38c5-639d-76a6-85e4-69e082fab2b8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:58:59 -08:00
Alexander Brown
c03cd17c87 chore: enable oxlint rule unicorn/no-lonely-if
17 violations across 12 files. All manually fixed by merging nested if conditions into combined && expressions. No inline disables needed.

Notable: deduplicated redundant dialog_close_on_mouse_leave checks in LGraphCanvas.ts (outer and inner if both checked the same flag).
Amp-Thread-ID: https://ampcode.com/threads/T-019c38bd-b37e-702f-88c4-cac54c012fc8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:58:38 -08:00
Alexander Brown
62108147c3 chore: enable oxlint rule unicorn/no-typeof-undefined
8 violations fixed across 5 files. All were safe property access on window/globalThis/local variables — converted typeof x === 'undefined' to x === undefined.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38b7-1552-748f-b5f1-28558b9402e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:10 -08:00
Alexander Brown
94523defc1 chore: enable oxlint rule vitest/warn-todo
Severity: warn (annotation, not blocking)

0 violations found. 2 existing it.todo() calls will appear as warnings.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38b2-c3b8-71fd-9681-66837588b9d7
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:09 -08:00
Alexander Brown
a4df681fb5 chore: enable oxlint rule vitest/consistent-test-filename
0 violations — guardrail rule enforcing .test.ts naming for test files in src/.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38ae-48df-7394-b615-5201058b4cc6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:07 -08:00
Alexander Brown
5f3fd7f7be chore: enable oxlint rule vitest/consistent-each-for
Guardrail rule enforcing .for() over .each() for parameterized tests.

0 violations found.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38a9-e1b3-754a-8609-2665c0a27fc5
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:06 -08:00
Alexander Brown
7c18a5a185 chore: enable oxlint rule eslint/prefer-spread
0 violations found. Guardrail rule enforcing spread syntax over .apply() patterns (e.g. Math.max(...args) over Math.max.apply(Math, args)).

Amp-Thread-ID: https://ampcode.com/threads/T-019c38a5-8149-7258-b2de-011e60870c8b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:04 -08:00
Alexander Brown
99bc407a1d chore: enable oxlint rule eslint/prefer-rest-params
0 violations found. Guardrail rule enforcing ...args over arguments object.

Amp-Thread-ID: https://ampcode.com/threads/T-019c389d-fb63-71b1-ab49-b5a1c8ee3447
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:01 -08:00
Alexander Brown
e60a475d8e chore: enable oxlint rule eslint/no-return-assign
29 violations fixed across 17 files. Converted arrow expression bodies with assignments to statement bodies, and removed return-value assignments from regular functions.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3893-7b2f-74f2-98db-41bbdee782d6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:00 -08:00
Alexander Brown
b56a028635 chore: enable oxlint rule eslint/no-new-func
Security guardrail — disallows new Function() (equivalent to eval).

0 violations found.

Amp-Thread-ID: https://ampcode.com/threads/T-019c388f-28d4-704a-a76b-c87393d45baa
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:57 -08:00
Alexander Brown
cc0bdf22e7 chore: enable oxlint rule eslint/preserve-caught-error
0 violations found — guardrail rule enforcing { cause: err } when re-throwing in catch blocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c388a-6748-75ba-9e5f-bacc83714f68
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:56 -08:00
Alexander Brown
82b4be3988 chore: enable oxlint rule eslint/no-useless-concat
0 violations found — guardrail rule preventing concatenation of adjacent string literals.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3885-c77f-76dc-8f05-a1de1c72cf28
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:54 -08:00
Alexander Brown
5540d22f64 chore: enable oxlint rule eslint/no-throw-literal
13 violations, all in litegraph. Wrapped string/template literal throws with new Error().

Files: ContextMenu.ts, LiteGraphGlobal.ts, LGraph.ts, LGraphNode.ts, LGraphCanvas.ts
Amp-Thread-ID: https://ampcode.com/threads/T-019c387e-a22a-722b-a017-fac342453e76
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:53 -08:00
Alexander Brown
a3cef10edf chore: enable oxlint rule eslint/no-useless-call
2 violations found, both false positives in useCachedRequest.test.ts where .call(null) invokes an object method named 'call', not Function.prototype.call. Added inline disables with justification.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3878-879e-75b2-8290-3006270b31d0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:51 -08:00
Alexander Brown
deb9603b30 chore: enable oxlint rule unicorn/no-useless-collection-argument
9 violations fixed across 5 files.

Removed empty array args from Set/Map constructors (new Set([]) -> new Set()) and unnecessary ?? [] fallbacks (new Set(x ?? []) -> new Set(x)) since collection constructors handle undefined.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3871-8533-745a-9f66-9641cacfc473
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:49 -08:00
Alexander Brown
82b83460fc chore: enable oxlint rule unicorn/no-useless-switch-case
15 violations across 11 files. All were empty case clauses falling through directly to default — removed the redundant case labels.

Amp-Thread-ID: https://ampcode.com/threads/T-019c386a-7f3e-7141-ac49-cd6203103ef4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:47 -08:00
Alexander Brown
cd3b1c5afe chore: enable oxlint rule unicorn/no-this-assignment
4 violations in litegraph code. Refactored prompt close() and showSearchBox close() to arrow functions. Inline-disabled 3 where hoisted function declarations genuinely need outer this (ContextMenu inner_onclick, showConnectionMenu inner_clicked, showSearchBox select/refreshHelper).

Amp-Thread-ID: https://ampcode.com/threads/T-019c385c-c19d-7298-9572-7651ebbf68a0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:45 -08:00
Alexander Brown
aa7e59deaa chore: enable oxlint rule unicorn/no-abusive-eslint-disable
0 violations found. Guardrail rule disallowing blanket eslint-disable/oxlint-disable comments without specifying rule names.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3857-b5df-7192-886e-dcbf9cc8357a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:43 -08:00
Alexander Brown
4fc40e0039 chore: enable oxlint rule unicorn/error-message
0 violations found — guardrail rule requiring message arg when constructing Error/TypeError/etc.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3852-b601-732e-b86b-a85266327c90
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:41 -08:00
Alexander Brown
ac5c84b514 chore: enable oxlint rule vitest/no-conditional-tests
0 violations found. Guardrail rule disallowing if/ternary wrapping it/test/describe blocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c384e-648e-7299-8539-91a298c83abc
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:39 -08:00
Alexander Brown
0b1c2217e5 chore: enable oxlint rule vitest/hoisted-apis-on-top
0 violations found — guardrail rule ensuring vi.mock, vi.unmock, vi.hoisted are at file top level.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3846-699e-7777-b31b-baa3e0a92c43
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:38 -08:00
Alexander Brown
2269275bd9 chore: enable oxlint rule eslint/no-unneeded-ternary
5 violations fixed across 3 files:

- measure.ts: condition ? false : true → !(condition)

- LGraphCanvas.ts: condition ? false : true → negated conditions

- coreSettings.ts (3): isCloud ? false : true → !isCloud, isCloud ? true : false → isCloud

Configured with defaultAssignment: false.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3840-1a10-763b-9a6f-2e14e29395a9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:36 -08:00
Alexander Brown
37f8b73cfd chore: enable oxlint rule eslint/operator-assignment
1 violation fixed: currentStep.value = currentStep.value - 1 → currentStep.value -= 1 in useUploadModelWizard.ts

Amp-Thread-ID: https://ampcode.com/threads/T-019c383a-a0af-7187-be7c-a9dbcbb20ced
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:34 -08:00
Alexander Brown
7741a9bbb2 chore: enable oxlint rule eslint/yoda
Disallows Yoda conditions (e.g. 'red' === value). Configured with 'never' and exceptRange: true. 0 violations found — guardrail rule.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3836-2e2e-75bc-9d0d-36420bdd8fad
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:33 -08:00
Alexander Brown
19bcbce4a6 chore: enable oxlint rule eslint/prefer-object-has-own
5 violations auto-fixed across 3 files. Replaced Object.prototype.hasOwnProperty.call() with Object.hasOwn().

Amp-Thread-ID: https://ampcode.com/threads/T-019c382f-0e79-7471-b0cc-6c6432e7ce6b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:31 -08:00
Alexander Brown
53fa5c22c1 chore: enable oxlint rule eslint/prefer-object-spread
5 violations across 3 files. 3 auto-fixed to object spread, 2 inline-disabled (LiteGraph class instance spread loses methods; array spread overwrites length/Symbol.iterator). Removed unused DefaultOptions and HasShowSearchCallback types. Removed redundant position default (always overwritten by required optPass.position).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3826-0f23-70fe-ac56-513f4f83c86d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:29 -08:00
Alexander Brown
8d53bbf263 chore: enable oxlint rule eslint/prefer-const
0 violations — guardrail rule enforcing const when never reassigned.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3821-72c3-75bc-8d82-058490bade7a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:27 -08:00
Alexander Brown
0d274344a1 chore: enable oxlint rule eslint/no-var
0 violations found — var is not used in src/. Guardrail rule.

Amp-Thread-ID: https://ampcode.com/threads/T-019c381d-3e37-7629-a0a6-423f01fcac19
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:25 -08:00
Alexander Brown
e6ec331a71 chore: enable oxlint rule eslint/no-useless-constructor
1 violation fixed: removed empty constructor from mock class in useSelectedLiteGraphItems.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c381b-011d-725f-8b44-1616bbb37dfe
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:25 -08:00
Alexander Brown
8c38d8a5de chore: enable oxlint rule eslint/eqeqeq
283 violations fixed across 31 files. Configured with [always, {null: ignore}] to allow idiomatic == null checks.

Added String() coercion for NodeId comparisons against string proxy widget IDs. Replaced @ts-expect-error directives with proper (e.target as Element) casts in LGraphCanvas.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3805-11cc-7475-80bb-47de0d690fc4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:23 -08:00
Alexander Brown
9f525bb540 chore: enable oxlint rule unicorn/no-negation-in-equality-check
0 violations found. Guardrail against bugs like !foo === bar.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3800-f5ff-740f-9d31-c32bbd842a7d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:21 -08:00
Alexander Brown
25a25efbb2 chore: enable oxlint rule typescript/prefer-ts-expect-error
0 violations found — no @ts-ignore usages exist in src/.

This rule ensures @ts-expect-error is used instead of @ts-ignore, which is safer because it errors when the suppression is no longer needed.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37ff-d1fc-72ae-8064-4f5546a78c38
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:19 -08:00
Alexander Brown
933fc35f02 chore: enable oxlint rule unicorn/prefer-classlist-toggle
1 violation in GraphView.vue: if/else classList.add/remove converted to classList.toggle('dark-theme', !light_theme)

Amp-Thread-ID: https://ampcode.com/threads/T-019c37fa-bed2-7283-98ca-a2c7d5c3aa91
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:17 -08:00
Alexander Brown
27a5701636 chore: enable oxlint rule unicorn/prefer-spread
86 violations fixed across 59 files. Converted Array.from(x) to [...x], .concat() to spread, and .slice() to spread.

3 inline disables: 2 in useBrushDrawing.ts (ArrayBuffer not iterable), 1 in NodeSettings.vue (spread widens union type).

Amp-Thread-ID: https://ampcode.com/threads/T-019c37f1-73b2-7147-90b3-282867667e38
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:15 -08:00
Alexander Brown
af48faee96 chore: enable oxlint rule unicorn/prefer-query-selector
11 violations auto-fixed across 8 files. Converted getElementById to querySelector and getElementsByTagName to querySelectorAll/querySelector. Added generic type params to querySelector calls where needed for type safety. Updated 2 test spies in SignInForm.test.ts to use scoped querySelector mocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37de-071a-7047-9ac3-585e2082c082
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:13 -08:00
Alexander Brown
4c5e213cd5 chore: enable oxlint rule unicorn/no-useless-undefined
84 violations fixed across 39 files. Configured with
checkArguments: false to avoid conflicts with TypeScript
function signatures requiring explicit undefined args.

Resolved 11 vue/return-in-computed-property eslint conflicts
by restructuring computed properties to use ternary expressions
or lookup objects instead of bare returns. 1 inline disable
used where restructuring was impractical.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37c4-8030-7088-b95c-2ef35bbec64e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:10 -08:00
Alexander Brown
a98d01b2a2 chore: enable oxlint rule unicorn/prefer-math-min-max
1 violation in queueStore.ts: ternary comparison replaced with Math.max(idx, 0)

Amp-Thread-ID: https://ampcode.com/threads/T-019c37bf-46e1-772b-aafa-dcc5824d21c3
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:08 -08:00
Alexander Brown
8fe2ed3efe chore: enable oxlint rule unicorn/prefer-prototype-methods
0 violations found. Rule enforces Array.prototype.slice.apply() over [].slice.apply().

Amp-Thread-ID: https://ampcode.com/threads/T-019c37bb-4101-756f-a11a-43929dd76f58
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:05 -08:00
Alexander Brown
e981c38df7 chore: enable oxlint rule unicorn/prefer-type-error
1 violation fixed in useComboWidget.test.ts: Error -> TypeError after typeof check.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37b9-2b26-71eb-9e39-834413472473
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:05 -08:00
Alexander Brown
a516c1cb45 chore: enable oxlint rule unicorn/prefer-string-slice
24 violations auto-fixed across 17 files.

All .substring()/.substr() calls converted to .slice() — safe because all arguments are non-negative.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37b2-17fa-74f5-abae-f4e915c7a9a5
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:53:01 -08:00
Alexander Brown
7821b69ef7 chore: enable oxlint rule unicorn/prefer-string-replace-all
38 violations auto-fixed across 22 files. Replaces .replace(/regex/g, ...) with .replaceAll(string, ...) or .replaceAll(/regex/g, ...) as appropriate.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37a9-c58c-77fe-bbac-f0344f9debaf
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:42 -08:00
Alexander Brown
6a420f2896 chore: enable oxlint rule unicorn/prefer-regexp-test
2 violations in surveyNormalization.ts: .match() in boolean context replaced with RegExp#test().

Amp-Thread-ID: https://ampcode.com/threads/T-019c37a3-cf5d-71ce-94b8-35b49cb38319
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:39 -08:00
Alexander Brown
2d20de7e1f chore: enable oxlint rule unicorn/no-length-as-slice-end
0 violations found — rule acts as a guardrail for future code.

Amp-Thread-ID: https://ampcode.com/threads/T-019c379f-7f8d-7369-bfce-97aba5462e40
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:37 -08:00
Alexander Brown
4e9dc97ad5 chore: enable oxlint rule unicorn/prefer-array-flat-map
2 violations auto-fixed: .map().flat() converted to .flatMap() in SelectionToolbox.vue and serverConfigStore.ts

Amp-Thread-ID: https://ampcode.com/threads/T-019c3797-72f6-77f4-b097-999543928f71
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:36 -08:00
Alexander Brown
bb6ad22003 chore: enable oxlint rule unicorn/no-instanceof-array
0 violations found. Rule enforces Array.isArray() over instanceof Array.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3792-8934-7763-ab09-70ff08764435
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:35 -08:00
Alexander Brown
febc1951d4 chore: enable oxlint rule unicorn/catch-error-name
93 violations found, 92 auto-fixed, 1 inline-disabled (nested catch
in useErrorHandling.ts where outer scope already uses `error`).

Configured with `ignore: ["^error\\w+$"]` to allow `errorCaught` as
a catch variable name where renaming to `error` would shadow a
reactive `error` ref in the same scope (common pattern in composables
and stores).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3782-789b-75a8-9653-d5e030adda1d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:29 -08:00
Alexander Brown
d012682dec chore: enable oxlint rule unicorn/prefer-optional-catch-binding
21 violations auto-fixed across 17 files. All were unused catch binding parameters (e.g. catch (error) -> catch).

Amp-Thread-ID: https://ampcode.com/threads/T-019c377b-4568-7288-94da-08ca003caa04
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:33 -08:00
Alexander Brown
826f4b1d80 chore: enable oxlint rule unicorn/prefer-string-trim-start-end
0 violations found. Guardrail rule to enforce trimStart()/trimEnd() over trimLeft()/trimRight().

Amp-Thread-ID: https://ampcode.com/threads/T-019c3776-cdd3-747b-9fa7-013a5f364282
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:32 -08:00
Alexander Brown
832b34c381 chore: enable oxlint rule unicorn/throw-new-error
4 violations in src/utils/linkFixer.ts, all auto-fixed (added missing 'new' keyword).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3771-486f-727c-af65-8f53c9d3142a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:31 -08:00
Alexander Brown
74dbad2404 chore: enable oxlint rule vitest/prefer-describe-function-title
Enabled as warn severity. No violations found (0 warnings, 0 errors).

This rule auto-fixes describe('fnName', ...) to describe(fnName, ...) when fnName is an imported symbol, using the function reference as the title.

Amp-Thread-ID: https://ampcode.com/threads/T-019c376c-efce-716a-84a7-4b9070b42ac4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:29 -08:00
Alexander Brown
aaf33ce6ad chore: enable oxlint rule vitest/consistent-vitest-vi
0 violations found. Enforces vi.* over vitest.* in test files.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3768-5c10-72bf-a87e-13da5999876c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:27 -08:00
Alexander Brown
0bff3fe7ea feat: migrate all defineProps to reactive destructured pattern
Convert all 120 `const props = defineProps<...>()` and bare
`defineProps<...>()` usages to Vue 3.5 reactive destructured props.
Update all `props.X` references to direct destructured names in both
script and template sections.

Upgrade `vue/define-props-destructuring` oxlint rule from "warn" to
"error" to enforce the pattern going forward.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3716-74a2-7347-8b74-ad6ce2d8c9a6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:27 -08:00
Alexander Brown
cd70d7a576 feat: add vue/define-props-destructuring lint rule as warning
Amp-Thread-ID: https://ampcode.com/threads/T-019c3676-05f6-76e3-b673-165fa08c1b46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:24 -08:00
Alexander Brown
e974f88e86 refactor: migrate withDefaults to reactive destructured props
Replace all 14 usages of withDefaults(defineProps<...>(), {...}) with
Vue 3.5 reactive destructured props pattern across the codebase.

- Drop redundant `undefined` defaults (already the default for optional props)
- Rename `duration` computed to `effectiveDuration` in TransitionCollapse
  to avoid shadowing the destructured prop
- Remove phantom `runningNodeName` default in QueueJobItem (not in type)

Amp-Thread-ID: https://ampcode.com/threads/T-019c3676-05f6-76e3-b673-165fa08c1b46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:21 -08:00
Terry Jia
ad6f856a31 fix: terminal tabs fail to register due to useI18n() after await (#8717)
## Summary
useI18n() requires an active Vue component instance via
getCurrentInstance(), which returns null after an await in the setup
context. Since terminal tabs are loaded via dynamic import (await),
useI18n() was silently failing, preventing terminal tab registration.

Replace useI18n() calls with static English strings for the title field,
which is only used in command labels. The titleKey field already handles
reactive i18n in the UI via BottomPanel.vue's getTabDisplayTitle().

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/8624

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/44e0118e-5566-4299-84cf-72b63d85521a


after


https://github.com/user-attachments/assets/3e99fb81-7a81-4065-a889-3ab5a393d8cf

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8717-fix-terminal-tabs-fail-to-register-due-to-useI18n-after-await-3006d73d3650810fb011c08862434bd5)
by [Unito](https://www.unito.io)
2026-02-07 12:45:07 -08:00
Terry Jia
7ed71c7769 fix: exclude transient image URLs from ImageCompare workflow serialization (#8715)
## Summary
Image URLs set by onExecuted are execution results that don't exist on
other machines. Disable workflow persistence (widget.serialize) while
keeping prompt serialization (widget.options.serialize) so compare_view
is still sent to the backend.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8715-fix-exclude-transient-image-URLs-from-ImageCompare-workflow-serialization-3006d73d365081b8aa87c7e05cb25f2f)
by [Unito](https://www.unito.io)
2026-02-07 15:08:05 -05:00
Hunter
442eff1094 fix: use useI18n() instead of @/i18n import in PricingTableWorkspace (#8720)
## Summary

PricingTableWorkspace.vue was missed in #8704 which migrated all Vue
components from `import { t } from '@/i18n'` to `useI18n()` and upgraded
the lint rule to `error`. This breaks `pnpm lint` on main.

## Changes

- **What**: Removed `import { t } from '@/i18n'` and destructured `t`
from the existing `useI18n()` call. Moved `useI18n()` above static
initializers that reference `t`.

## Review Focus

The `billingCycleOptions` and `tiers` arrays call `t()` at module init
time — this is fine in `<script setup>` since `useI18n()` is called
first in the same synchronous scope.
2026-02-07 09:31:26 -08:00
Benjamin Lu
dd4d36d459 fix: route gtm through telemetry entrypoint (#8354)
Wire checkout attribution into GTM events and checkout POST payloads.

This updates the cloud telemetry flow so the backend team can correlate checkout events without relying on frontend cookie parsing. We now surface GA4 identity via a GTM-provided global and include attribution on both `begin_checkout` telemetry and the checkout POST body. The backend should continue to derive the Firebase UID from the auth header; the checkout POST body does not include a user ID.

GTM events pushed (unchanged list, updated payloads):
- `page_view` (page title/location/referrer as before)
- `sign_up` / `login`
- `begin_checkout` now includes:
  - `user_id`, `tier`, `cycle`, `checkout_type`, `previous_tier` (if change flow)
  - `ga_client_id`, `ga_session_id`, `ga_session_number`
  - `gclid`, `gbraid`, `wbraid`

Backend-facing change:
- `POST /customers/cloud-subscription-checkout/:tier` now includes a JSON body with attribution fields only:
  - `ga_client_id`, `ga_session_id`, `ga_session_number`
  - `gclid`, `gbraid`, `wbraid`
- Backend should continue to derive the Firebase UID from the auth header.

Required GTM setup:
- Provide `window.__ga_identity__` via a GTM Custom HTML tag (after GA4/Google tag) with `{ client_id, session_id, session_number }`. The frontend reads this to populate the GA fields.

<img width="1416" height="1230" alt="image" src="https://github.com/user-attachments/assets/b77cf0ed-be69-4497-a540-86e5beb7bfac" />

## Screenshots (if applicable)

<img width="991" height="385" alt="image" src="https://github.com/user-attachments/assets/8309cd9e-5ab5-4fba-addb-2d101aaae7e9"/>

Manual Testing:
<img width="3839" height="2020" alt="image" src="https://github.com/user-attachments/assets/36901dfd-08db-4c07-97b8-a71e6783c72f"/>
<img width="2141" height="851" alt="image" src="https://github.com/user-attachments/assets/2e9f7aa4-4716-40f7-b147-1c74b0ce8067"/>
<img width="2298" height="982" alt="image" src="https://github.com/user-attachments/assets/72cbaa53-9b92-458a-8539-c987cf753b02"/>
<img width="2125" height="999" alt="image" src="https://github.com/user-attachments/assets/4b22387e-8027-4f50-be49-a410282a1adc"/>

To manually test, you will need to override api/features in devtools to also return this:

```
"gtm_container_id": "GTM-NP9JM6K7"
```

┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8354-fix-route-gtm-through-telemetry-entrypoint-2f66d73d36508138afacdeffe835f28a) by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Analytics expanded: page view tracking, richer auth telemetry (includes user IDs), and checkout begin events with attribution.
  * Google Tag Manager support and persistent checkout attribution (GA/client/session IDs, gclid/gbraid/wbraid).

* **Chores**
  * Telemetry reworked to support multiple providers via a registry with cloud-only initialization.
  * Workflow module refactored for clearer exports.

* **Tests**
  * Added/updated tests for attribution, telemetry, and subscription flows.

* **CI**
  * New check prevents telemetry from leaking into distribution artifacts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-07 01:08:48 -08:00
Alexander Brown
69c8c84aef fix: resolve i18n no-restricted-imports lint warnings (#8704)
## Summary

Fix all i18n `no-restricted-imports` lint warnings and upgrade rules
from `warn` to `error`.

## Changes

- **What**: Migrate Vue components from `import { t/d } from '@/i18n'`
to `const { t } = useI18n()`. Migrate non-component `.ts` files from
`useI18n()` to `import { t/d } from '@/i18n'`. Allow `st` import from
`@/i18n` in Vue components (it wraps `te`/`t` for safe fallback
translation). Remove `@deprecated` tag from `i18n.ts` global exports
(still used by `st` and non-component code). Upgrade both lint rules
from `warn` to `error`.

## Review Focus

- The `st` helper is intentionally excluded from the Vue component
restriction since it provides safe fallback translation needed for
custom node definitions.

Fixes #8701

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8704-fix-resolve-i18n-no-restricted-imports-lint-warnings-2ff6d73d365081ae84d8eb0dfef24323)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-06 20:54:53 -08:00
Simula_r
c5431de123 Feat/workspaces 6 billing (#8508)
## Summary

Implements billing infrastructure for team workspaces, separate from
legacy personal billing.

## Changes

- **Billing abstraction**: New `useBillingContext` composable that
switches between legacy (personal) and workspace billing based on
context
- **Workspace subscription flows**: Pricing tables, plan transitions,
cancellation dialogs, and payment preview components for workspace
billing
- **Top-up credits**: Workspace-specific top-up dialog with polling for
payment confirmation
- **Workspace API**: Extended with billing endpoints (subscriptions,
invoices, payment methods, credits top-up)
- **Workspace switcher**: Now displays tier badges for each workspace
- **Subscribe polling**: Added polling mechanisms
(`useSubscribePolling`, `useTopupPolling`) for async payment flows

## Review Focus

- Billing flow correctness for workspace vs legacy contexts
- Polling timeout and error handling in payment flows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8508-Feat-workspaces-6-billing-2f96d73d365081f69f65c1ddf369010d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:52:53 -08:00
Benjamin Lu
030d4fd4d5 fix: hide assets sidebar badge when legacy queue is enabled (#8708)
### Motivation
- The Assets sidebar shows a notification-style badge immediately when a
job is queued using the legacy queue, which is misleading because that
badge is intended for the newer Queue Panel V2 experience.

### Description
- Gate the Assets sidebar `iconBadge` on the `Comfy.Queue.QPOV2` setting
by importing `useSettingStore` and returning `null` when QPO V2 is
disabled; otherwise show `pendingTasks.length` as before
(`src/composables/sidebarTabs/useAssetsSidebarTab.ts`).
- Add a focused unit test that mocks the settings and queue store to
verify the badge is hidden when QPO V2 is disabled and shows the pending
count when enabled
(`src/composables/sidebarTabs/useAssetsSidebarTab.test.ts`).
- Keep the Assets component import mocked in the test to avoid
bootstrapping the full UI during unit runs.

### Testing
- Ran the new unit test with `pnpm vitest
src/composables/sidebarTabs/useAssetsSidebarTab.test.ts` and it passed
(2 tests).
- Ran type checking with `pnpm typecheck` and it completed successfully.
- Ran linting with `pnpm lint` and no errors were reported.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69867f4ceaac8330b9806d5b51006a6a)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8708-fix-hide-assets-sidebar-badge-when-legacy-queue-is-enabled-3006d73d3650818eb809de399583088e)
by [Unito](https://www.unito.io)
2026-02-06 20:21:16 -08:00
Christian Byrne
473fa609e3 devex: add Playwright test writing Agent Skill (#8512)
Since there is so much ground to cover, tried to use the progressive
disclosure approach (often recommended when writing skills) such that
the agent only gets the info it needs for the given type of tests it is
writing.

Still need to try using the skill to write tests and iterate a bit from
there.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8512-devtool-add-Playwright-test-writing-Agent-Skill-2fa6d73d365081aaaf6dd186b9dcf8ce)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-06 19:15:56 -08:00
Terry Jia
a2c8324c0a fix: resolve 3D nodes missing after page refresh (#8711)
## Summary
Await all registerNodeDef calls in registerNodesFromDefs to prevent race
condition where lazy-loaded 3D node types (Load3D, Preview3D, SaveGLB)
are not registered in LiteGraph before workflow loading.

Replay lazily loaded extensions' beforeRegisterNodeDef hooks so that
input type modifications (e.g. Preview3D → PREVIEW_3D) are applied
correctly despite the extensions being registered mid-invocation.

Fixes the issue introduced by code splitting (#8542) where THREE.js lazy
import caused node registration to complete after workflow load.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/370545dc-4081-4164-83ed-331a092fc690

after

https://github.com/user-attachments/assets/bf9dc887-0076-41fe-93ad-ab0bb984c5ce
2026-02-06 22:05:44 -05:00
Terry Jia
d9ce4ff5e0 feat: render dragging links above Vue nodes via overlay canvas (#8695)
## Summary
When vueNodesMode is enabled, the dragging link preview was rendered on
the background canvas behind DOM-based Vue nodes, making it invisible
when overlapping node bodies.

Add a new overlay canvas layer between TransformPane and
SelectionRectangle that renders the dragging link preview and snap
highlight above the Vue node DOM layer.

Static connections remain on the background canvas as before.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/8414
discussed with @DrJKL 

## Screenshots
before

https://github.com/user-attachments/assets/94508efa-570c-4e32-a373-360b72625fdd

after

https://github.com/user-attachments/assets/4b0f924c-66ce-4f49-97d7-51e6e923a1b9

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8695-feat-render-dragging-links-above-Vue-nodes-via-overlay-canvas-2ff6d73d365081599b2fe18b87f34b7a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-06 21:19:18 -05:00
AustinMroz
e7932f2fc2 Tighter image sizing in vue mode (#8702)
Fixes multiple overlapping issues with both the ImagePreviews (LoadImage
node) and LivePreview (Ksampler node) to eliminate empty space and move
the bahviour to be closer to the litegraph implementation.
- NodeWidgets will no longer no longer flex-grow when it contains no
widgets capable of growing
<img width="278" height="585" alt="image"
src="https://github.com/user-attachments/assets/c4c39805-1474-457b-86d1-b64ecf01319f"
/>

- The number of element layers for LivePreview has been reduced. Sizing
is difficult to properly spread across nested flex levels.
- The ImagePreview and LivePreview now have `contain-size` set with a
min height of 220 pixels (the same as the litegraph implementation).
This allows images to "pillarbox" by increasing width without increasing
height.
  | Before | After |
  | ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/3fe38a20-47d3-4a77-a0db-63167f76b0be"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/22dc6bf6-1812-49bb-95a1-3febfb3e40dd"
/>|
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/99b24547-6850-4b46-a972-53411676c78f"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/0a7783c8-cf93-47aa-8c60-608b9a4b4498"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8702-Tighter-image-sizing-in-vue-mode-2ff6d73d3650814196f0d55d81a42e2d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-06 16:39:26 -08:00
Christian Byrne
f53b0879ed feat: add model-to-node mappings for cloud asset categories (#8468)
## Summary

Add mappings for 13 previously unmapped model categories in the Cloud
asset browser, enabling users to click on models to create corresponding
loader nodes on the canvas.

## Changes

### Core nodes
- `latent_upscale_models` → `LatentUpscaleModelLoader`

### Extension nodes
| Category | Node Class | Widget Key |
|----------|-----------|-----------|
| `sam2` | `DownloadAndLoadSAM2Model` | `model` |
| `sams` | `SAMLoader` | `model_name` |
| `ultralytics` | `UltralyticsDetectorProvider` | `model_name` |
| `depthanything` | `DownloadAndLoadDepthAnythingV2Model` | `model` |
| `ipadapter` | `IPAdapterModelLoader` | `ipadapter_file` |
| `segformer_b2_clothes` | `LS_LoadSegformerModel` | `model_name` |
| `segformer_b3_clothes` | `LS_LoadSegformerModel` | `model_name` |
| `segformer_b3_fashion` | `LS_LoadSegformerModel` | `model_name` |
| `nlf` | `LoadNLFModel` | `nlf_model` |
| `FlashVSR` | `FlashVSRNode` | (auto-load) |
| `FlashVSR-v1.1` | `FlashVSRNode` | (auto-load) |

### Hierarchical fallback
- `ultralytics/bbox` and `ultralytics/segm` fall back to the
`ultralytics` mapping

### Skipped categories
- `vae_approx` - No user-facing loader (used internally for latent
previews)
- `detection` - No specific loader exists

## Testing
- Added unit tests for all new mappings
- Tests verify hierarchical fallback works correctly
- All 40 tests pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8468-feat-add-model-to-node-mappings-for-cloud-asset-categories-2f86d73d365081389ea5fbfc52ecbfad)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-06 16:04:27 -08:00
Alexander Brown
5441f70cd5 feat: enforce i18n import conventions via ESLint (#8701)
## Summary

Enforce i18n import conventions via ESLint: Vue components must use
`useI18n()`, non-composable `.ts` files must use the global `t` from
`@/i18n`.

## Changes

- **What**: Two new `no-restricted-imports` rules in `eslint.config.ts`
(both `warn` severity for incremental migration). Removed the disabled
`@/i18n--to-enable` placeholder from `.oxlintrc.json`.
- `.vue` files: disallow importing `t`/`d`/`st`/`te` from `@/i18n` (37
existing violations to migrate)
- Non-composable `.ts` files: disallow importing `useI18n` from
`vue-i18n` (2 existing violations)
- Composable `use*.ts`, test files, and `src/i18n.ts` are excluded from
Rule 2

## Review Focus

- The rules are placed after `oxlint.buildFromOxlintConfigFile()` to
re-enable ESLint's `no-restricted-imports` for these specific file
scopes without conflicting with oxlint's base rule (which handles
PrimeVue deprecations).
- `warn` severity chosen so CI is not blocked while existing violations
are migrated.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8701-feat-enforce-i18n-import-conventions-via-ESLint-2ff6d73d36508123b6f9edf2693fb5e0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-06 14:03:00 -08:00
pythongosssss
0e3314bbd3 Node ghost mode when adding nodes (#8694)
## Summary

Adds option for adding a node as a "ghost" that follows the cursor until
the user left clicks to confirm, or esc/right click to cancel.

## Changes

- **What**: 
Adds option for `ghost` when calling `graph.add`  
This adds the node with a `flag` of ghost which causes it to render
transparent
Selects the node, then sets the canvas as dragging to stick the node to
the cursor

## Screenshots (if applicable)



https://github.com/user-attachments/assets/dcb5702f-aba3-4983-aa40-c51f24a4767a

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8694-Node-ghost-mode-when-adding-nodes-2ff6d73d3650815591f2c28415050463)
by [Unito](https://www.unito.io)
2026-02-06 13:42:38 -08:00
pythongosssss
8f301ec94b Fix hit detection on vue node slots (#8609)
## Summary

Vue node slots extend outside the bounds of the node:
<img width="123" height="107" alt="image"
src="https://github.com/user-attachments/assets/96f7f28b-de54-4978-bc78-f38fc1fd4ea1"
/>
When clicking on the outer half of the slot, the matching node is not
found as the click was technically not over a node, however in reality
the action should still be associated with the node the slot is for.

This specifically fixes middle click to add reroute not working on the
outer half of the slot.

## Changes

- **What**: 
- If the event is not over a node, check if is over a Vue slot, if so,
use the node associated with that slot.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8609-Fix-hit-detection-on-vue-node-slots-2fd6d73d3650815c8328f9ea8fa66b0c)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Added comprehensive test suite for slot hit-detection in Vue nodes
mode, covering standard and fallback interaction paths.

* **Bug Fixes**
* Improved hit-detection accuracy for slots that extend beyond node
boundaries in Vue mode, ensuring clicks map to the correct node.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-06 13:41:18 -08:00
Terry Jia
17c1b1f989 fix: prevent node height shrinking on vueNodes/litegraph mode switch (#8697)
## Summary
Three interacting bugs caused cumulative height loss (~66px per cycle):

1. useVueNodeLifecycle incorrectly subtracted NODE_TITLE_HEIGHT from
LGraphNode.size[1] during layout init, but size[1] is already
content-only (title excluded per LGraphNode.measure()).

2. ensureCorrectLayoutScale added/subtracted NODE_TITLE_HEIGHT in scale
formulas, breaking the round-trip.
Simplified to pure ratio scaling (size * scaleFactor). 
Also set LayoutSource.Canvas before batchUpdateNodeBounds to prevent
stale DOM source from triggering incorrect height normalization.

3. initSizeStyles set --node-height (min-height of the full DOM element
including title) to the content-only layout height.
Added NODE_TITLE_HEIGHT so ResizeObserver captures the correct total
height and normalization recovers the exact content height.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/ae41124b-f9e3-4061-8127-eeacddc67a55

after

https://github.com/user-attachments/assets/5ff288a6-73a3-481a-a750-150d9bdbc8fe

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8697-fix-prevent-node-height-shrinking-on-vueNodes-litegraph-mode-switch-2ff6d73d365081c7a2acdc7ec57e2e19)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-06 12:52:13 -08:00
AustinMroz
a4b725b85e Fix legacy history (#8687)
Restores functionality of the history and queue sections in the legacy
"floating menu" which were broken in #7650

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8687-Fix-legacy-history-2ff6d73d3650810a8a05f2ee18cbfa1d)
by [Unito](https://www.unito.io)
2026-02-06 10:14:55 -08:00
869 changed files with 21037 additions and 6510 deletions

View File

@@ -0,0 +1,200 @@
---
name: writing-playwright-tests
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
---
# Writing Playwright Tests for ComfyUI_frontend
## Golden Rules
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
- Assets live in `browser_tests/assets/`
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
## Vue Nodes vs LiteGraph: Decision Guide
Choose based on **what you're testing**, not personal preference:
| Testing... | Use | Why |
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
| Both in same test | Pick primary, minimize switching | Avoid confusion |
**Vue Nodes requires explicit opt-in:**
```typescript
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
```
**Vue Node state uses CSS classes:**
```typescript
const BYPASS_CLASS = /before:bg-bypass\/60/
await expect(node).toHaveClass(BYPASS_CLASS)
```
## Common Issues
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
| Symptom | Common Cause | Typical Fix |
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
## Test Tags
Add appropriate tags to every test:
| Tag | When to Use |
| ------------- | ----------------------------------------- |
| `@smoke` | Quick essential tests |
| `@slow` | Tests > 10 seconds |
| `@screenshot` | Visual regression tests |
| `@canvas` | Canvas interactions |
| `@node` | Node-related |
| `@widget` | Widget-related |
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
| `@2x` | HiDPI tests (runs on 2x scale project) |
```typescript
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
```
## Retry Patterns
**Never use `waitForTimeout`** - it's always wrong.
| Pattern | Use Case |
| ------------------------ | ---------------------------------------------------- |
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
| `expect.poll()` | Single value polling |
| `expect().toPass()` | Multiple assertions that must all pass |
```typescript
// Prefer auto-retrying assertions when possible
await expect(node).toBeVisible()
// Single value polling
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
// Multiple conditions
await expect(async () => {
expect(await node1.getValue()).toBe('foo')
expect(await node2.getValue()).toBe('bar')
}).toPass({ timeout: 2000 })
```
## Screenshot Baselines
- **Screenshots are Linux-only.** Don't commit local screenshots.
- **To update baselines:** Add PR label `New Browser Test Expectations`
- **Mask dynamic content:**
```typescript
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
mask: [page.locator('.timestamp')]
})
```
## CI Debugging
1. Download artifacts from failed CI run
2. Extract and view trace: `npx playwright show-trace trace.zip`
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
4. Reproduce CI: `CI=true pnpm test:browser`
5. Local runs: `pnpm test:browser:local`
## Anti-Patterns
Avoid these common mistakes:
1. **Arbitrary waits** - Use retrying assertions instead
```typescript
// ❌ await page.waitForTimeout(500)
// ✅ await expect(element).toBeVisible()
```
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
```typescript
// ❌ page.locator('div.container > button.btn-primary')
// ✅ page.getByTestId('submit-button')
```
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
```typescript
await node.drag({ x: 50, y: 50 })
await comfyPage.nextFrame() // Required
```
4. **Shared state between tests** - Tests must be independent
```typescript
// ❌ let sharedData // Outside test
// ✅ Define state inside each test
```
## Quick Start Template
```typescript
// Path depends on test file location - adjust '../' segments accordingly
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('should do something', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('myWorkflow')
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
// ... test logic
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
})
})
```
## Finding Patterns
```bash
# Find similar tests
grep -r "KSampler" browser_tests/tests/
# Find usage of a fixture method
grep -r "loadWorkflow" browser_tests/tests/
# Find tests with specific tag
grep -r '@screenshot' browser_tests/tests/
```
## Key Files to Read
| Purpose | Path |
| ----------------- | ------------------------------------------ |
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
| Helper classes | `browser_tests/fixtures/helpers/` |
| Component objects | `browser_tests/fixtures/components/` |
| Test selectors | `browser_tests/fixtures/selectors.ts` |
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
| Test assets | `browser_tests/assets/` |
| Existing tests | `browser_tests/tests/` |
**Read the fixture code directly** - it's the source of truth for available methods.

View File

@@ -0,0 +1,52 @@
name: 'CI: Dist Telemetry Scan'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
- name: Scan dist for telemetry references
run: |
set -euo pipefail
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'Google Tag Manager' \
-e '(?i)\bgtm\.js\b' \
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
dist; then
echo 'Telemetry references found in dist assets.'
exit 1
fi
echo 'No telemetry references found in dist assets.'

2
.gitignore vendored
View File

@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp

View File

@@ -21,6 +21,7 @@
"eslint",
"import",
"oxc",
"promise",
"typescript",
"unicorn",
"vitest",
@@ -28,6 +29,12 @@
],
"rules": {
"no-async-promise-executor": "off",
"no-else-return": [
"error",
{
"allowElseIf": false
}
],
"no-console": [
"error",
{
@@ -35,8 +42,29 @@
}
],
"no-control-regex": "off",
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"func-style": [
"error",
"declaration",
{
"allowArrowFunctions": true
}
],
"no-eval": "off",
"no-new-func": "error",
// TODO: Enable and fix 104 violations
"no-param-reassign": "off",
"no-redeclare": "error",
"no-return-assign": ["error", "always"],
"no-throw-literal": "error",
"no-useless-constructor": "error",
"no-var": "error",
"no-restricted-imports": [
"error",
{
@@ -60,24 +88,70 @@
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": ["st", "t", "te", "d"],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
],
"no-unneeded-ternary": [
"error",
{
"defaultAssignment": false
}
],
"no-useless-call": "error",
"no-useless-concat": "error",
"prefer-const": "error",
// TODO: Enable and fix 581 violations
"prefer-destructuring": "off",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"promise/no-nesting": "error",
"promise/param-names": "error",
// TODO: Enable and fix 76 violations
"promise/prefer-await-to-callbacks": "off",
// TODO: Enable and fix 91 violations
"promise/prefer-await-to-then": "off",
"promise/prefer-catch": "error",
"preserve-caught-error": "error",
"yoda": [
"error",
"never",
{
"exceptRange": true
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"operator-assignment": ["error", "always"],
"import/default": "error",
"import/export": "error",
"import/first": ["error", "absolute-first"],
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"describe": "for"
}
],
"vitest/consistent-test-filename": [
"error",
{
"pattern": ".*\\.test\\.ts$"
}
],
"vitest/consistent-vitest-vi": "error",
"vitest/warn-todo": "warn",
"vitest/hoisted-apis-on-top": "error",
"vitest/no-conditional-tests": "error",
"vitest/prefer-describe-function-title": "error",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
@@ -87,11 +161,55 @@
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/catch-error-name": [
"error",
{
"ignore": ["^error\\w+$"]
}
],
// TODO: Enable and fix 147 violations
"unicorn/consistent-function-scoping": "off",
"unicorn/error-message": "error",
"unicorn/no-abusive-eslint-disable": "error",
// TODO: Enable and fix 165 violations
"unicorn/no-array-for-each": "off",
"unicorn/no-immediate-mutation": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-lonely-if": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-array-flat-map": "error",
"unicorn/no-empty-file": "off",
"unicorn/no-new-array": "off",
"unicorn/prefer-add-event-listener": "error",
"unicorn/prefer-array-find": "error",
"unicorn/no-useless-undefined": [
"error",
{
"checkArguments": false,
"checkArrowFunctionBody": false
}
],
"unicorn/prefer-classlist-toggle": "error",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-this-assignment": "error",
"unicorn/no-useless-collection-argument": "error",
"unicorn/no-useless-switch-case": "error",
"unicorn/no-useless-fallback-in-spread": "off",
"unicorn/no-useless-spread": "off",
"unicorn/prefer-optional-catch-binding": "error",
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-query-selector": "error",
"unicorn/prefer-spread": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-has": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/prefer-string-trim-start-end": "error",
"unicorn/prefer-type-error": "error",
"unicorn/throw-new-error": "error",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
@@ -101,6 +219,12 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
// TODO: Enable and fix 372 violations (use { "ignoreConditionalTests": true })
"typescript/prefer-nullish-coalescing": "off",
// TODO: Enable and fix violations
"typescript/prefer-optional-chain": "off",
"typescript/prefer-ts-expect-error": "error",
"vue/define-props-destructuring": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
@@ -119,7 +243,8 @@
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
"unicorn/no-empty-file": "error",
"vitest/consistent-test-filename": "off"
}
}
]

View File

@@ -98,12 +98,10 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
keepNames: true,
strictExecutionOrder: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -59,7 +59,7 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind"]
"ignoreFunctions": ["theme", "v-bind", "from-folder", "from-json"]
}
]
},

View File

@@ -2,57 +2,57 @@
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
# Topbar
/src/components/topbar/ @pythongosssss
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Legacy UI
/scripts/ui/ @pythongosssss
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs

View File

@@ -201,7 +201,7 @@ The project supports three types of icons, all with automatic imports (no manual
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i class="icon-[lucide--settings]" />`, `<i class="icon-[mdi--folder]" />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Tailwind CSS icon classes (`icon-[comfy--template]`) are provided by `@iconify/tailwind4`, configured in `packages/design-system/src/css/style.css`. Custom icons are stored in `packages/design-system/src/icons/` and loaded via `from-folder` at build time.
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).

View File

@@ -4,11 +4,40 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
## Directory Structure
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state
```text
browser_tests/
├── assets/ - Test data (JSON workflows, images)
├── fixtures/
│ ├── ComfyPage.ts - Main fixture (delegates to helpers)
│ ├── ComfyMouse.ts - Mouse interaction helper
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
│ ├── selectors.ts - Centralized TestIds
│ ├── components/ - Page object components
│ │ ├── ContextMenu.ts
│ │ ├── SettingDialog.ts
│ │ ├── SidebarTab.ts
│ │ └── Topbar.ts
│ ├── helpers/ - Focused helper classes
│ │ ├── CanvasHelper.ts
│ │ ├── CommandHelper.ts
│ │ ├── KeyboardHelper.ts
│ │ ├── NodeOperationsHelper.ts
│ │ ├── SettingsHelper.ts
│ │ ├── WorkflowHelper.ts
│ │ └── ...
│ └── utils/ - Utility functions
├── helpers/ - Test-specific utilities
└── tests/ - Test files (*.spec.ts)
```
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint
## Skill Documentation
A Playwright test-writing skill exists at `.claude/skills/writing-playwright-tests/SKILL.md`.
The skill documents **meta-level guidance only** (gotchas, anti-patterns, decision guides). It does **not** duplicate fixture APIs - agents should read the fixture code directly in `browser_tests/fixtures/`.

View File

@@ -0,0 +1,599 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [210, 108],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1135, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [456, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [347, 231],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [325, 109],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -298,7 +298,10 @@ test.describe('Settings', () => {
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Keybinding.NewBindings'
(req) =>
req.url().includes('/api/settings') &&
!req.url().includes('/api/settings/') &&
req.method() === 'POST'
)
// Save keybinding

View File

@@ -0,0 +1,162 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
}
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const nodeId = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
return node.id
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
return { nodeId, centerX, centerY }
}
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
return comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
if (!node) return null
return { ghost: !!node.flags.ghost }
}, nodeId)
}
for (const mode of ['litegraph', 'vue'] as const) {
test.describe(`Ghost node placement (${mode} mode)`, () => {
test.beforeEach(async ({ comfyPage }) => {
await setVueMode(comfyPage, mode === 'vue')
})
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
const centerY = Math.round(viewport.height / 2)
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.nextFrame()
const result = await comfyPage.page.evaluate(
([clientX, clientY]) => {
const node = window.LiteGraph!.createNode('VAEDecode')!
const event = new MouseEvent('click', { clientX, clientY })
window.app!.graph.add(node, { ghost: true, dragEvent: event })
const canvas = window.app!.canvas
const rect = canvas.canvas.getBoundingClientRect()
const cursorCanvasX =
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
const cursorCanvasY =
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
return {
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
diffY: node.pos[1] - 10 - cursorCanvasY
}
},
[centerX, centerY] as const
)
await comfyPage.nextFrame()
expect(Math.abs(result.diffX)).toBeLessThan(5)
expect(Math.abs(result.diffY)).toBeLessThan(5)
})
test('left-click confirms ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY)
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).not.toBeNull()
expect(after!.ghost).toBe(false)
})
test('Escape cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Delete cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
const { nodeId } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
test('right-click cancels ghost placement', async ({ comfyPage }) => {
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
const before = await getNodeById(comfyPage, nodeId)
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
await comfyPage.nextFrame()
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
})
})
}

View File

@@ -123,17 +123,14 @@ test.describe('Workflows sidebar', () => {
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json',
'workflow4.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -220,24 +217,22 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow2.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
'workflow1.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow1.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
})
test('Does not report warning when switching between opened workflows', async ({

View File

@@ -0,0 +1,100 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
const isInSubgraph = () =>
comfyPage.page.evaluate(
() => window.app!.canvas.graph?.isRootGraph === false
)
expect(await isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph()).toBe(false)
})
})

View File

@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 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: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -31,7 +31,12 @@ test.describe('Vue Integer Widget', () => {
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
await comfyPage.vueNodes.getNodeByTitle('Int').click()
// Click on the header to select the node (clicking center may land on
// the widget area where pointerdown.stop prevents node selection)
await comfyPage.vueNodes
.getNodeByTitle('Int')
.locator('.lg-node-header')
.click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -3,7 +3,6 @@
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,

View File

@@ -12,6 +12,18 @@ This guide covers patterns and examples for testing Vue components in the ComfyU
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Describe Block Naming
Use `Component.__name ?? 'ComponentName'` for the top-level `describe` title. This passes the function reference (satisfying the `prefer-describe-function-title` lint rule) while providing a readable fallback:
```typescript
import MyComponent from './MyComponent.vue'
describe(MyComponent.__name ?? 'MyComponent', () => {
// ...
})
```
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
@@ -21,7 +33,7 @@ Basic approach to testing a component's rendering and structure:
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe('SidebarIcon', () => {
describe(SidebarIcon.__name ?? 'SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false

View File

@@ -138,6 +138,10 @@ export default defineConfig([
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'vue/return-in-computed-property': [
'error',
{ treatUndefinedAsUnspecified: false }
],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
@@ -279,5 +283,46 @@ export default defineConfig([
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{
files: ['**/*.vue'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@/i18n',
importNames: ['t', 'd', 'te'],
message:
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
}
]
}
]
}
},
// Non-composable .ts files must use the global t/d/te, not useI18n()
{
files: ['**/*.ts'],
ignores: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'vue-i18n',
importNames: ['useI18n'],
message:
"useI18n() requires Vue setup context. Use `import { t } from '@/i18n'` instead."
}
]
}
]
}
}
])

14
global.d.ts vendored
View File

@@ -5,8 +5,14 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -30,6 +36,14 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}
interface Navigator {

View File

@@ -35,18 +35,6 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -65,36 +53,6 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

@@ -20,10 +20,6 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/design-system': {
entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -31,6 +27,7 @@ const config: KnipConfig = {
ignoreBinaries: ['python3', 'gh'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.8",
"version": "1.40.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -18,7 +18,7 @@
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
@@ -193,7 +193,7 @@
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
"vite": "catalog:"
}
}
}

View File

@@ -4,7 +4,6 @@
"description": "Shared design system for ComfyUI Frontend",
"type": "module",
"exports": {
"./tailwind-config": "./tailwind.config.ts",
"./css/*": "./src/css/*"
},
"scripts": {
@@ -12,7 +11,7 @@
},
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind": "catalog:"
"@iconify/tailwind4": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -7,11 +7,16 @@
@plugin 'tailwindcss-primeui';
@config '../../tailwind.config.ts';
@plugin "@iconify/tailwind4" {
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
@custom-variant touch (@media (hover: none));
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

View File

@@ -1,100 +0,0 @@
import { existsSync, readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const dirName = dirname(fileName)
const customIconsPath = join(dirName, 'icons')
// Iconify collection structure
interface IconifyIcon {
body: string
width?: number
height?: number
}
interface IconifyCollection {
prefix: string
icons: Record<string, IconifyIcon>
width?: number
height?: number
}
// Create an Iconify collection for custom icons
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {},
width: 16,
height: 16
}
/**
* Validates that an SVG file contains valid SVG content
*/
function validateSvgContent(content: string, filename: string): void {
if (!content.trim()) {
throw new Error(`Empty SVG file: ${filename}`)
}
if (!content.includes('<svg')) {
throw new Error(`Invalid SVG file (missing <svg> tag): ${filename}`)
}
// Basic XML structure validation
const openTags = (content.match(/<svg[^>]*>/g) || []).length
const closeTags = (content.match(/<\/svg>/g) || []).length
if (openTags !== closeTags) {
throw new Error(`Malformed SVG file (mismatched svg tags): ${filename}`)
}
}
/**
* Loads custom SVG icons from the icons directory
*/
function loadCustomIcons(): void {
if (!existsSync(customIconsPath)) {
console.warn(`Custom icons directory not found: ${customIconsPath}`)
return
}
try {
const files = readdirSync(customIconsPath)
const svgFiles = files.filter((file) => file.endsWith('.svg'))
if (svgFiles.length === 0) {
console.warn('No SVG files found in custom icons directory')
return
}
svgFiles.forEach((file) => {
const name = file.replace('.svg', '')
const filePath = join(customIconsPath, file)
try {
const content = readFileSync(filePath, 'utf-8')
validateSvgContent(content, file)
iconCollection.icons[name] = {
body: content
}
} catch (error) {
console.error(
`Failed to load custom icon ${file}:`,
error instanceof Error ? error.message : error
)
// Continue loading other icons instead of failing the entire build
}
})
} catch (error) {
console.error(
'Failed to read custom icons directory:',
error instanceof Error ? error.message : error
)
// Don't throw here - allow build to continue without custom icons
}
}
// Load icons when this module is imported
loadCustomIcons()

View File

@@ -251,26 +251,25 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
The icon system has two layers:
1. **Build-time Processing** (`packages/design-system/src/iconCollection.ts`):
- Scans `packages/design-system/src/icons/` for SVG files
- Validates SVG content and structure
- Creates Iconify collection for Tailwind CSS
- Provides error handling for malformed files
1. **Tailwind CSS Plugin** (`@iconify/tailwind4`):
- Configured via `@plugin` directive in `packages/design-system/src/css/style.css`
- Uses `from-folder(comfy, ...)` to load SVGs from `packages/design-system/src/icons/`
- Auto-cleans and optimizes SVGs at build time
2. **Vite Runtime** (`vite.config.mts`):
- Enables direct SVG import as Vue components
- Supports dynamic icon loading
```typescript
// Build script creates Iconify collection
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {
workflow: { body: '<svg>...</svg>' },
node: { body: '<svg>...</svg>' }
}
```css
/* CSS configuration for Tailwind icon classes */
@plugin "@iconify/tailwind4" {
prefix: 'icon';
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
```
```typescript
// Vite configuration for component-based usage
Icons({
compiler: 'vue3',

View File

@@ -1,24 +0,0 @@
import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' }
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './src/iconCollection'
export default {
theme: {
extend: {
boxShadow: {
interface: 'var(--interface-panel-box-shadow)'
}
}
},
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection,
lucide
},
scale: 1.2,
prefix: 'icon'
})
]
}

1218
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ catalog:
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
'@iconify/tailwind4': ^1.2.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@lobehub/i18n-cli': ^1.25.1
'@nx/eslint': 22.2.6
@@ -69,9 +69,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
oxfmt: ^0.31.0
oxlint: ^1.46.0
oxlint-tsgolint: ^0.11.5
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -33,6 +33,7 @@ fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
'.oxlintrc.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then

View File

@@ -283,34 +283,27 @@ else
done
unset IFS
# Determine overall status
# Determine overall status (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="Passed"
else
status_icon="🕵🏻"
status_text="No test results"
fi
# Generate concise completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Tests: $status_icon **$status_text**"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
# Build flaky indicator if any (small subtext, no warning icon)
flaky_note=""
if [ $total_flaky -gt 0 ]; then
flaky_note=" · $total_flaky flaky"
fi
# Generate compact single-line comment
comment="$COMMENT_MARKER
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"

View File

@@ -16,11 +16,12 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
import { app } from '@/scripts/app'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -42,7 +43,7 @@ const showContextMenu = (event: MouseEvent) => {
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
if (isElectron()) {
if (isDesktop) {
document.addEventListener('contextmenu', showContextMenu)
}

View File

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
import {
downloadFile,
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
let mockIsCloud = false
@@ -46,7 +49,7 @@ describe('downloadUtil', () => {
vi.unstubAllGlobals()
})
describe('downloadFile', () => {
describe(downloadFile, () => {
it('should create and trigger download with basic URL', () => {
const testUrl = 'https://example.com/image.png'
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
expect(mockLink.download).toBe('user-friendly.png')
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi
.fn()
.mockReturnValue(
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('中文.png')
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('my-fallback.png')
})
})
describe(extractFilenameFromContentDisposition, () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})
it('returns null for empty header', () => {
expect(extractFilenameFromContentDisposition('')).toBeNull()
})
it('extracts filename from simple quoted format', () => {
const header = 'attachment; filename="test-file.png"'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from unquoted format', () => {
const header = 'attachment; filename=test-file.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from RFC 5987 format', () => {
const header = "attachment; filename*=UTF-8''test%20file.png"
expect(extractFilenameFromContentDisposition(header)).toBe(
'test file.png'
)
})
it('prefers RFC 5987 format over simple format', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'preferred.png'
)
})
it('handles unicode characters in RFC 5987 format', () => {
const header =
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
})
it('falls back to simple format when RFC 5987 decoding fails', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
})
it('handles header with only attachment disposition', () => {
const header = 'attachment'
expect(extractFilenameFromContentDisposition(header)).toBeNull()
})
it('handles case-insensitive filename parameter', () => {
const header = 'attachment; FILENAME="test.png"'
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
})
})
})

View File

@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
}
}
/**
* Extract filename from Content-Disposition header
* Handles both simple format: attachment; filename="name.png"
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
* @param header - The Content-Disposition header value
* @returns The extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(
header: string | null
): string | null {
if (!header) return null
// Try RFC 5987 extended format first (filename*=UTF-8''...)
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
if (extendedMatch?.[1]) {
try {
return decodeURIComponent(extendedMatch[1])
} catch {
// Fall through to simple format
}
}
// Try simple quoted format: filename="..."
const quotedMatch = header.match(/filename="([^"]+)"/i)
if (quotedMatch?.[1]) {
return quotedMatch[1]
}
// Try unquoted format: filename=...
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
if (unquotedMatch?.[1]) {
return unquotedMatch[1]
}
return null
}
const downloadViaBlobFetch = async (
href: string,
filename: string
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
const headerFilename =
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(filename, blob)
downloadBlob(headerFilename ?? fallbackFilename, blob)
}

View File

@@ -172,19 +172,17 @@ const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const firstPanelStyle = computed(() =>
sidebarLocation.value === 'left'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
const lastPanelStyle = computed(() => {
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const lastPanelStyle = computed(() =>
sidebarLocation.value === 'right'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
</script>
<style scoped>

View File

@@ -18,9 +18,8 @@ import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -30,7 +29,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}
}))
vi.mock('@/utils/envUtil')
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isNightly: false,
get isDesktop() {
return mockData.isDesktop
}
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -103,10 +108,12 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
})
describe('authentication state', () => {
@@ -129,7 +136,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
vi.mocked(isElectron).mockReturnValue(true)
mockData.isDesktop = true
const wrapper = createWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
@@ -235,7 +242,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
return
})
}

View File

@@ -153,7 +153,7 @@ import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -163,7 +163,6 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
@@ -288,9 +287,8 @@ const openCustomNodeManager = async () => {
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
} catch (error) {
console.error(error)
console.error(toastError)
}
}
}

View File

@@ -8,10 +8,10 @@
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton

View File

@@ -54,7 +54,7 @@ vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe('WorkspaceAuthGate', () => {
describe(WorkspaceAuthGate.__name ?? 'WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true

View File

@@ -51,7 +51,7 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
describe('EssentialsPanel', () => {
describe(EssentialsPanel.__name ?? 'EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})

View File

@@ -23,7 +23,7 @@ vi.mock('vue-i18n', () => ({
})
}))
describe('ShortcutsList', () => {
describe(ShortcutsList.__name ?? 'ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',

View File

@@ -55,10 +55,17 @@ vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => false),
electronAPI: vi.fn(() => null)
}))
const mockData = vi.hoisted(() => ({ isDesktop: false }))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockData.isDesktop
}
}))
// Mock clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
@@ -99,7 +106,7 @@ const mountBaseTerminal = () => {
})
}
describe('BaseTerminal', () => {
describe(BaseTerminal.__name ?? 'BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {

View File

@@ -35,7 +35,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
@@ -85,7 +86,7 @@ const showContextMenu = (event: MouseEvent) => {
electronAPI()?.showContextMenu({ type: 'text' })
}
if (isElectron()) {
if (isDesktop) {
useEventListener(terminalEl, 'contextmenu', showContextMenu)
}

View File

@@ -62,8 +62,8 @@ const terminalCreated = (
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
} catch (error) {
console.error('Error loading logs', error)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'

View File

@@ -78,9 +78,7 @@ interface Props {
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { item, isActive = false } = defineProps<Props>()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -103,7 +101,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -127,13 +125,13 @@ const rename = async (
}
}
const isRoot = props.item.key === 'root'
const isRoot = item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return props.item.label
return item.label
})
const startRename = async () => {
@@ -145,7 +143,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = props.item.label as string
itemLabel.value = item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (props.isActive) {
if (isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
item.command?.({ item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
} else if (isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
await rename(itemLabel.value, item.label as string)
}
isEditing.value = false

View File

@@ -7,123 +7,128 @@ import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
describe(
ColorCustomizationSelector.__name ?? 'ColorCustomizationSelector',
() => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(
colorOptions.length + 1
)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
})
)

View File

@@ -5,7 +5,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = defineProps<{
const { renderFunction } = defineProps<{
renderFunction: () => HTMLElement
}>()
@@ -14,12 +14,12 @@ const container = ref<HTMLElement | null>(null)
function renderContent() {
if (container.value) {
container.value.innerHTML = ''
const element = props.renderFunction()
const element = renderFunction()
container.value.appendChild(element)
}
}
onMounted(renderContent)
watch(() => props.renderFunction, renderContent)
watch(() => renderFunction, renderContent)
</script>

View File

@@ -52,7 +52,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
const props = defineProps<{
const { modelValue, initialIcon, initialColor } = defineProps<{
modelValue: boolean
initialIcon?: string
initialColor?: string
@@ -64,7 +64,7 @@ const emit = defineEmits<{
}>()
const visible = computed({
get: () => props.modelValue,
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
@@ -96,17 +96,13 @@ const defaultIcon = iconOptions.find(
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
iconOptions.find((option) => option.value === initialIcon) || defaultIcon
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
@@ -119,7 +115,7 @@ const closeDialog = () => {
}
watch(
() => props.modelValue,
() => modelValue,
(newValue: boolean) => {
if (newValue) {
resetCustomization()

View File

@@ -5,7 +5,7 @@
{{ col.header }}
</div>
<div>
{{ formatValue(props.device[col.field], col.field) }}
{{ formatValue(device[col.field], col.field) }}
</div>
</template>
</div>
@@ -15,7 +15,7 @@
import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { device } = defineProps<{
device: DeviceStats
}>()

View File

@@ -6,7 +6,7 @@ import { createApp } from 'vue'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
describe(EditableText.__name ?? 'EditableText', () => {
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})

View File

@@ -5,10 +5,10 @@
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
<span class="file-type" :title="displayHint">{{ displayLabel }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
<div v-if="error" class="file-error">
{{ error }}
</div>
</div>
@@ -18,14 +18,14 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
v-if="(status === null || status === 'error') && !!url"
variant="secondary"
size="sm"
@click="copyURL"
@@ -53,7 +53,7 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
@@ -66,7 +66,7 @@
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
@@ -78,7 +78,7 @@
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
@@ -98,7 +98,7 @@ import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { url, hint, label, error } = defineProps<{
url: string
hint?: string
label?: string
@@ -106,9 +106,9 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const displayLabel = computed(() => label || url.split('/').pop())
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
@@ -117,10 +117,10 @@ const fileSize = computed(() =>
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
const [savePath, filename] = label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
const download = downloads.find((download) => url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
@@ -132,17 +132,17 @@ electronDownloadStore.$subscribe((_, { downloads }) => {
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const triggerCancelDownload = () => electronDownloadStore.cancel(url)
const triggerPauseDownload = () => electronDownloadStore.pause(url)
const triggerResumeDownload = () => electronDownloadStore.resume(url)
const copyURL = async () => {
await copyToClipboard(props.url)
await copyToClipboard(url)
}
</script>

View File

@@ -5,10 +5,7 @@
:ref="
(el) => {
if (el)
mountCustomExtension(
props.extension as CustomExtension,
el as HTMLElement
)
mountCustomExtension(extension as CustomExtension, el as HTMLElement)
}
"
/>
@@ -19,17 +16,17 @@ import { onBeforeUnmount } from 'vue'
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
const props = defineProps<{
const { extension } = defineProps<{
extension: VueExtension | CustomExtension
}>()
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
extension.render(el)
const mountCustomExtension = (ext: CustomExtension, el: HTMLElement) => {
ext.render(el)
}
onBeforeUnmount(() => {
if (props.extension.type === 'custom' && props.extension.destroy) {
props.extension.destroy()
if (extension.type === 'custom' && extension.destroy) {
extension.destroy()
}
})
</script>

View File

@@ -3,35 +3,35 @@
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
<span :title="displayHint">{{ displayLabel }}</span>
</div>
<Message
v-if="props.error"
v-if="error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:title="error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
{{ error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
:disabled="!!error"
:title="url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
<Button variant="secondary" :disabled="!!error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
@@ -47,22 +47,22 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { url, hint, label, error } = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const displayLabel = computed(() => label || url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
await copyToClipboard(url)
}
const { copyToClipboard } = useCopyToClipboard()

View File

@@ -10,7 +10,7 @@ import ColorPicker from 'primevue/colorpicker'
import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
const { label } = defineProps<{
label?: string
}>()

View File

@@ -45,7 +45,7 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
const { modelValue } = defineProps<{
modelValue: string
}>()
@@ -64,9 +64,9 @@ const handleFileUpload = (event: Event) => {
if (target.files && target.files[0]) {
const file = target.files[0]
const reader = new FileReader()
reader.onload = (e) => {
reader.addEventListener('load', (e) => {
emit('update:modelValue', e.target?.result as string)
}
})
reader.readAsDataURL(file)
}
}

View File

@@ -2,16 +2,12 @@
<template>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex grow items-center">
<span
:id="`${props.id}-label`"
class="text-muted"
:class="props.labelClass"
>
<span :id="`${id}-label`" class="text-muted" :class="labelClass">
<slot name="name-prefix" />
{{ props.item.name }}
{{ item.name }}
<i
v-if="props.item.tooltip"
v-tooltip="props.item.tooltip"
v-if="item.tooltip"
v-tooltip="item.tooltip"
class="pi pi-info-circle bg-transparent"
/>
<slot name="name-suffix" />
@@ -19,11 +15,11 @@
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
:is="markRaw(getFormComponent(item))"
:id="id"
v-model:model-value="formValue"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
:aria-labelledby="`${id}-label`"
v-bind="getFormAttrs(item)"
/>
</div>
</div>
@@ -48,35 +44,37 @@ import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
const formValue = defineModel<unknown>('formValue')
const props = defineProps<{
const { item, id, labelClass } = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }
const inputType = item.type
function getFormAttrs(formItem: FormItem) {
const attrs = { ...(formItem.attrs || {}) }
const inputType = formItem.type
if (typeof inputType === 'function') {
attrs['renderFunction'] = () =>
inputType(
props.item.name,
(v: unknown) => (formValue.value = v),
formItem.name,
(v: unknown) => {
formValue.value = v
},
formValue.value,
item.attrs
formItem.attrs
)
}
switch (item.type) {
switch (formItem.type) {
case 'combo':
case 'radio':
attrs['options'] =
typeof item.options === 'function'
typeof formItem.options === 'function'
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
// (value) => [string | {text: string, value: string}]
item.options(formValue.value)
: item.options
formItem.options(formValue.value)
: formItem.options
if (typeof item.options?.[0] !== 'string') {
if (typeof formItem.options?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
@@ -85,11 +83,11 @@ function getFormAttrs(item: FormItem) {
return attrs
}
function getFormComponent(item: FormItem): Component {
if (typeof item.type === 'function') {
function getFormComponent(formItem: FormItem): Component {
if (typeof formItem.type === 'function') {
return CustomFormValue
}
switch (item.type) {
switch (formItem.type) {
case 'boolean':
return ToggleSwitch
case 'number':

View File

@@ -5,239 +5,242 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
import FormRadioGroup from './FormRadioGroup.vue'
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
describe(
(FormRadioGroup as { __name?: string }).__name ?? 'FormRadioGroup',
() => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
})
)

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
const { modelValue, options, optionLabel, optionValue, id } = defineProps<{
modelValue: T
options?: (string | SettingOption | Record<string, string>)[]
optionLabel?: string
@@ -39,9 +39,9 @@ defineEmits<{
}>()
const normalizedOptions = computed<SettingOption[]>(() => {
if (!props.options) return []
if (!options) return []
return props.options.map((option) => {
return options.map((option) => {
if (typeof option === 'string') {
return { text: option, value: option }
}
@@ -54,8 +54,8 @@ const normalizedOptions = computed<SettingOption[]>(() => {
}
// Handle optionLabel/optionValue
return {
text: option[props.optionLabel || 'text'] || 'Unknown',
value: option[props.optionValue || 'value']
text: option[optionLabel || 'text'] || 'Unknown',
value: option[optionValue || 'value']
}
})
})

View File

@@ -30,24 +30,25 @@ import InputNumber from 'primevue/inputnumber'
import Knob from 'primevue/knob'
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const { modelValue, inputClass, knobClass, min, max, step, resolution } =
defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(props.modelValue)
const localValue = ref(modelValue)
watch(
() => props.modelValue,
() => modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -56,18 +57,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(props.min) || 0
newValue = Number(min) || 0
}
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(min, Math.min(max, newValue))
newValue = Math.max(minVal, Math.min(maxVal, newValue))
// Round to the nearest step
newValue = Math.round(newValue / step) * step
newValue = Math.round(newValue / stepVal) * stepVal
// Update local value and emit change
localValue.value = newValue
@@ -76,11 +77,11 @@ const updateValue = (newValue: number | null) => {
const displayValue = (value: number): string => {
updateValue(value)
const stepString = (props.step ?? 1).toString()
const resolution = stepString.includes('.')
const stepString = (step ?? 1).toString()
const decimalPlaces = stepString.includes('.')
? stepString.split('.')[1].length
: 0
return value.toFixed(props.resolution ?? resolution)
return value.toFixed(resolution ?? decimalPlaces)
}
defineOptions({

View File

@@ -29,7 +29,7 @@ import InputNumber from 'primevue/inputnumber'
import Slider from 'primevue/slider'
import { ref, watch } from 'vue'
const props = defineProps<{
const { modelValue, inputClass, sliderClass, min, max, step } = defineProps<{
modelValue: number
inputClass?: string
sliderClass?: string
@@ -42,10 +42,10 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(props.modelValue)
const localValue = ref(modelValue)
watch(
() => props.modelValue,
() => modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -54,18 +54,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(props.min) || 0
newValue = Number(min) || 0
}
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(min, Math.min(max, newValue))
newValue = Math.max(minVal, Math.min(maxVal, newValue))
// Round to the nearest step
newValue = Math.round(newValue / step) * step
newValue = Math.round(newValue / stepVal) * stepVal
// Update local value and emit change
localValue.value = newValue

View File

@@ -41,7 +41,6 @@ const spinnerSizeClass = computed(() => {
switch (size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'md':
default:
return 'h-12 w-12 border-4'
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-results-placeholder h-full p-8" :class="props.class">
<div :class="cn('no-results-placeholder h-full p-8', className)">
<Card>
<template #content>
<div class="flex flex-col items-center">
@@ -25,8 +25,16 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
class: className,
icon,
title,
message,
textClass,
buttonLabel
} = defineProps<{
class?: string
icon?: string
title: string

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
}
})
describe('SearchBox', () => {
describe((SearchBox as { __name?: string }).__name ?? 'SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()

View File

@@ -18,7 +18,7 @@ export interface SearchFilter {
id: string | number
}
defineProps<Omit<SearchFilter, 'id'>>()
const { text, badge, badgeClass } = defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>

View File

@@ -21,9 +21,9 @@
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.devices') }}
</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabView v-if="stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
v-for="device in stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
@@ -31,7 +31,7 @@
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
<DeviceInfo v-else :device="stats.devices[0]" />
</div>
</template>
</div>
@@ -48,16 +48,16 @@ import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { stats } = defineProps<{
stats: SystemStats
}>()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
...stats.system,
argv: stats.system.argv.join(' ')
}))
const hasDevices = computed(() => props.stats.devices.length > 0)
const hasDevices = computed(() => stats.devices.length > 0)
type SystemInfoKey = keyof SystemStats['system']

View File

@@ -4,7 +4,7 @@
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="props.class"
:class="className"
:value="renderedRoot.children"
selection-mode="single"
:pt="{
@@ -37,10 +37,6 @@
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
@@ -60,6 +56,10 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -68,7 +68,7 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
const { root, class: className } = defineProps<{
root: TreeExplorerNode
class?: string
}>()
@@ -90,7 +90,7 @@ const {
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(props.root)
const renderedRoot = fillNodeInfo(root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
messages: {}
})
describe('TreeExplorerTreeNode', () => {
describe(TreeExplorerTreeNode.__name ?? 'TreeExplorerTreeNode', () => {
const mockNode = {
key: '1',
label: 'Test Node',

View File

@@ -5,21 +5,21 @@
'tree-node',
{
'can-drop': canDrop,
'tree-folder': !props.node.leaf,
'tree-leaf': props.node.leaf
'tree-folder': !node.leaf,
'tree-leaf': node.leaf
}
]"
:data-testid="`tree-node-${node.key}`"
>
<div class="node-content">
<span class="node-label">
<slot name="before-label" :node="props.node" />
<slot name="before-label" :node="node" />
<EditableText
:model-value="node.label"
:is-editing="isEditing"
@edit="handleRename"
/>
<slot name="after-label" :node="props.node" />
<slot name="after-label" :node="node" />
</span>
<Badge
v-if="showNodeBadgeText"
@@ -31,7 +31,7 @@
<div
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
<slot name="actions" :node="node" />
</div>
</div>
</template>
@@ -52,7 +52,7 @@ import type {
TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const props = defineProps<{
const { node } = defineProps<{
node: RenderedTreeExplorerNode
}>()
@@ -67,20 +67,20 @@ const emit = defineEmits<{
}>()
const nodeBadgeText = computed<string>(() => {
if (props.node.leaf) {
if (node.leaf) {
return ''
}
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
if (node.badgeText !== undefined && node.badgeText !== null) {
return node.badgeText
}
return props.node.totalLeaves.toString()
return node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const isEditing = computed<boolean>(() => node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(props.node, newName)
handleEditLabel?.(node, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -89,21 +89,21 @@ const canDrop = ref(false)
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (props.node.draggable) {
if (node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: props.node
data: node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node),
onGenerateDragPreview: props.node.renderDragPreview
onDragStart: () => emit('dragStart', node),
onDrop: () => emit('dragEnd', node),
onGenerateDragPreview: node.renderDragPreview
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview?.(container)
return node.renderDragPreview?.(container)
},
nativeSetDragImage
})
@@ -112,14 +112,14 @@ if (props.node.draggable) {
})
}
if (props.node.droppable) {
if (node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData)
await node.handleDrop?.(dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
emit('itemDropped', node, dndData.data)
}
},
onDragEnter: (event) => {

View File

@@ -6,10 +6,11 @@ import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
import UrlInput from './UrlInput.vue'
describe(UrlInput.__name ?? 'UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -17,7 +17,7 @@
'pi pi-times cursor-pointer text-red-500':
validationState === ValidationState.INVALID
}"
@click="validateUrl(props.modelValue)"
@click="validateUrl(model)"
/>
</IconField>
</template>
@@ -32,40 +32,34 @@ import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
const model = defineModel<string>({ required: true })
const { validateUrlFn } = defineProps<{
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'state-change': [state: ValidationState]
}>()
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
value ? value.replaceAll(/\s+/g, '') : ''
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
const internalValue = ref(cleanInput(model.value))
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
}
)
watch(model, async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
})
watch(validationState, (newState) => {
emit('state-change', newState)
})
// Validate on mount
onMounted(async () => {
await validateUrl(props.modelValue)
await validateUrl(model.value)
})
const handleInput = (value: string | undefined) => {
@@ -87,7 +81,7 @@ const handleBlur = async () => {
}
// Emit the update only on blur
emit('update:modelValue', normalizedUrl)
model.value = normalizedUrl
}
// Default validation implementation
@@ -113,7 +107,7 @@ const validateUrl = async (value: string) => {
validationState.value = ValidationState.LOADING
try {
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
const isValid = await (validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? ValidationState.VALID
: ValidationState.INVALID

View File

@@ -23,7 +23,7 @@ const i18n = createI18n({
}
})
describe('UserAvatar', () => {
describe(UserAvatar.__name ?? 'UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -39,7 +39,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
describe('UserCredit', () => {
describe(UserCredit.__name ?? 'UserCredit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBalance.value = {

View File

@@ -422,8 +422,9 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose } = defineProps<{
const { onClose: originalOnClose, initialCategory = 'all' } = defineProps<{
onClose: () => void
initialCategory?: string
}>()
// Track session time for telemetry
@@ -443,7 +444,6 @@ const distributions = computed(() => {
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
@@ -547,7 +547,7 @@ const allTemplates = computed(() => {
})
// Navigation
const selectedNavItem = ref<string | null>('all')
const selectedNavItem = ref<string | null>(initialCategory)
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
@@ -594,12 +594,10 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort') {
} else if (source === 'sort' && isPopularNav && !isPopularSort) {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
selectedNavItem.value = 'all'
}
}
@@ -680,37 +678,37 @@ const runsOnOptions = computed(() =>
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
}
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
}
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
})
const runsOnFilterLabel = computed(() => {
if (selectedRunsOnObjects.value.length === 0) {
return t('templateWorkflows.runsOnFilter', 'Runs On')
} else if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
} else {
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
}
if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
}
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
})
// Sort options

View File

@@ -26,7 +26,7 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
return true
}
return undefined
return
}
onMounted(() => {

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