Merge branch 'main' into sno-api-changelog

Resolve conflict in knip.config.ts by keeping demo-snapshots/** ignore
entry alongside new main entries.
This commit is contained in:
snomiao
2026-03-13 01:59:34 +00:00
2708 changed files with 388950 additions and 81444 deletions

View File

@@ -17,19 +17,19 @@ sequenceDiagram
Frontend->>WebSocket: Connect
WebSocket-->>Frontend: Connection established
Note over Frontend: First message must be feature flags
Frontend->>WebSocket: Send client feature flags
WebSocket->>Backend: Receive feature flags
Backend->>FeatureFlags Module: Store client capabilities
Backend->>FeatureFlags Module: Get server features
FeatureFlags Module-->>Backend: Return server capabilities
Backend->>WebSocket: Send server feature flags
WebSocket-->>Frontend: Receive server features
Note over Frontend,Backend: Both sides now know each other's capabilities
Frontend->>Frontend: Store server features
Frontend->>Frontend: Components use useFeatureFlags()
```
@@ -44,15 +44,15 @@ graph TB
D[useFeatureFlags composable] --> B
E[Vue Components] --> D
end
subgraph Backend
F[feature_flags.py] --> G[SERVER_FEATURE_FLAGS]
H[server.py WebSocket] --> F
I[Feature Consumers] --> F
end
C <--> H
style A fill:#f9f,stroke:#333,stroke-width:2px
style G fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#9ff,stroke:#333,stroke-width:2px
@@ -98,19 +98,19 @@ classDiagram
+supports_feature(sockets_metadata, sid, feature_name) bool
+get_connection_feature(sockets_metadata, sid, feature_name, default) Any
}
class PromptServer {
-sockets_metadata: Dict
+websocket_handler()
+send()
}
class FeatureConsumer {
<<interface>>
+check_feature()
+use_feature()
}
PromptServer --> FeatureFlagsModule
FeatureConsumer --> FeatureFlagsModule
```
@@ -127,19 +127,19 @@ classDiagram
+serverSupportsFeature(name) boolean
+getServerFeature(name, default) T
}
class useFeatureFlags {
+serverSupports(name) boolean
+getServerFeature(name, default) T
+createServerFeatureFlag(name) ComputedRef
+extension: ExtensionFlags
}
class VueComponent {
<<component>>
+setup()
}
ComfyApi <-- useFeatureFlags
VueComponent --> useFeatureFlags
```
@@ -153,12 +153,13 @@ graph LR
A[Preview Generation] --> B{supports_preview_metadata?}
B -->|Yes| C[Send metadata with preview]
B -->|No| D[Send preview only]
C --> E[Enhanced preview with node info]
D --> F[Basic preview image]
```
**Backend Usage:**
```python
# Check if client supports preview metadata
if feature_flags.supports_feature(
@@ -189,13 +190,14 @@ graph TB
B --> C{File size OK?}
C -->|Yes| D[Upload file]
C -->|No| E[Show error]
F[Backend] --> G[Set from CLI args]
G --> H[Convert MB to bytes]
H --> I[Include in feature flags]
```
**Backend Configuration:**
```python
# In feature_flags.py
SERVER_FEATURE_FLAGS = {
@@ -205,6 +207,7 @@ SERVER_FEATURE_FLAGS = {
```
**Frontend Usage:**
```typescript
const { getServerFeature } = useFeatureFlags()
const maxUploadSize = getServerFeature('max_upload_size', 100 * 1024 * 1024) // Default 100MB
@@ -215,10 +218,11 @@ const maxUploadSize = getServerFeature('max_upload_size', 100 * 1024 * 1024) //
### Frontend Access Patterns
1. **Direct API access:**
```typescript
// Check boolean feature
if (api.serverSupportsFeature('supports_preview_metadata')) {
// Feature is supported
// Feature is supported
}
// Get feature value with default
@@ -226,21 +230,23 @@ const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
```
2. **Using the composable (recommended for reactive components):**
```typescript
const { serverSupports, getServerFeature, extension } = useFeatureFlags()
// Check feature support
if (serverSupports('supports_preview_metadata')) {
// Use enhanced previews
// Use enhanced previews
}
// Use reactive convenience properties (automatically update if flags change)
if (extension.manager.supportsV4.value) {
// Use V4 manager API
// Use V4 manager API
}
```
3. **Reactive usage in templates:**
```vue
<template>
<div v-if="featureFlags.extension.manager.supportsV4">
@@ -262,12 +268,12 @@ const featureFlags = useFeatureFlags()
```python
# Check if a specific client supports a feature
if feature_flags.supports_feature(
sockets_metadata,
client_id,
sockets_metadata,
client_id,
"supports_preview_metadata"
):
# Client supports this feature
# Get feature value with default
max_size = feature_flags.get_connection_feature(
sockets_metadata,
@@ -282,6 +288,7 @@ max_size = feature_flags.get_connection_feature(
### Backend
1. **For server capabilities**, add to `SERVER_FEATURE_FLAGS` in `comfy_api/feature_flags.py`:
```python
SERVER_FEATURE_FLAGS = {
"supports_preview_metadata": True,
@@ -291,6 +298,7 @@ SERVER_FEATURE_FLAGS = {
```
2. **Use in your code:**
```python
if feature_flags.supports_feature(sockets_metadata, sid, "your_new_feature"):
# Feature-specific code
@@ -299,28 +307,34 @@ if feature_flags.supports_feature(sockets_metadata, sid, "your_new_feature"):
### Frontend
1. **For client capabilities**, add to `src/config/clientFeatureFlags.json`:
```json
{
"supports_preview_metadata": false,
"your_new_feature": true
"supports_preview_metadata": false,
"your_new_feature": true
}
```
2. **For extension features**, update the composable to add convenience accessors:
```typescript
// In useFeatureFlags.ts
const extension = {
manager: {
supportsV4: computed(() => getServerFeature('extension.manager.supports_v4', false))
},
yourExtension: {
supportsNewFeature: computed(() => getServerFeature('extension.yourExtension.supports_new_feature', false))
}
manager: {
supportsV4: computed(() =>
getServerFeature('extension.manager.supports_v4', false)
)
},
yourExtension: {
supportsNewFeature: computed(() =>
getServerFeature('extension.yourExtension.supports_new_feature', false)
)
}
}
return {
// ... existing returns
extension
// ... existing returns
extension
}
```
@@ -332,7 +346,7 @@ graph LR
A --> C[Only frontend supports]
A --> D[Only backend supports]
A --> E[Neither supports]
B --> F[Feature enabled]
C --> G[Feature disabled]
D --> H[Feature disabled]
@@ -340,6 +354,7 @@ graph LR
```
Test your feature flags with different combinations:
- Frontend with flag + Backend with flag = Feature works
- Frontend with flag + Backend without = Graceful degradation
- Frontend without + Backend with flag = No feature usage
@@ -350,13 +365,14 @@ Test your feature flags with different combinations:
```typescript
// In tests-ui/tests/api.featureFlags.test.ts
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Mock server doesn't support feature
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Mock server doesn't support feature
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
```

View File

@@ -5,6 +5,7 @@
ComfyUI frontend uses a comprehensive settings system for user preferences with support for dynamic defaults, version-based rollouts, and environment-aware configuration.
### Settings Architecture
- Settings are defined as `SettingParams` in `src/constants/coreSettings.ts`
- Registered at app startup, loaded/saved via `useSettingStore` (Pinia)
- Persisted per user via backend `/settings` endpoint
@@ -45,6 +46,7 @@ await newUserService().initializeIfNewUser(settingStore)
## Dynamic and Environment-Based Defaults
### Computed Defaults
You can compute defaults dynamically using function defaults that access runtime context:
```typescript
@@ -65,14 +67,15 @@ You can compute defaults dynamically using function defaults that access runtime
```
### Version-Based Defaults
You can vary defaults by installed frontend version using `defaultsByInstallVersion`:
```typescript
// From src/stores/settingStore.ts:129-150
function getVersionedDefaultValue<K extends keyof Settings, TValue = Settings[K]>(
key: K,
param: SettingParams<TValue> | undefined
): TValue | null {
function getVersionedDefaultValue<
K extends keyof Settings,
TValue = Settings[K]
>(key: K, param: SettingParams<TValue> | undefined): TValue | null {
const defaultsByInstallVersion = param?.defaultsByInstallVersion
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
const installedVersion = get('Comfy.InstalledVersion')
@@ -166,15 +169,13 @@ The initial installed version is captured for new users to ensure versioned defa
```typescript
// From src/services/newUserService.ts:49-53
await settingStore.set(
'Comfy.InstalledVersion',
__COMFYUI_FRONTEND_VERSION__
)
await settingStore.set('Comfy.InstalledVersion', __COMFYUI_FRONTEND_VERSION__)
```
## Practical Patterns for Environment-Based Defaults
### Dynamic Default Patterns
```typescript
// Device-based default
{
@@ -199,6 +200,7 @@ await settingStore.set(
```
### Version-Based Rollout Pattern
```typescript
{
id: 'Comfy.Example.NewFeature',
@@ -214,6 +216,7 @@ await settingStore.set(
## Settings Persistence and Access
### API Interaction
Values are stored per user via the backend. The store writes through API and falls back to defaults when not set:
```typescript
@@ -224,6 +227,7 @@ await api.storeSetting(key, newValue)
```
### Usage in Components
```typescript
const settingStore = useSettingStore()
@@ -234,7 +238,6 @@ const value = settingStore.get('Comfy.SomeSetting')
await settingStore.set('Comfy.SomeSetting', newValue)
```
## Advanced Settings Features
### Migration and Backward Compatibility
@@ -243,10 +246,7 @@ Settings support migration from deprecated values:
```typescript
// From src/stores/settingStore.ts:68-69, 172-175
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
clonedValue
)
const newValue = tryMigrateDeprecatedValue(settingsById.value[key], clonedValue)
// Migration happens during addSetting for existing values:
if (settingValues.value[setting.id] !== undefined) {
@@ -263,8 +263,8 @@ Settings can define onChange callbacks that receive the setting definition, new
```typescript
// From src/stores/settingStore.ts:73, 177
onChange(settingsById.value[key], newValue, oldValue) // During set()
onChange(setting, get(setting.id), undefined) // During addSetting()
onChange(settingsById.value[key], newValue, oldValue) // During set()
onChange(setting, get(setting.id), undefined) // During addSetting()
```
### Settings UI and Categories
@@ -290,4 +290,4 @@ Settings are automatically grouped for UI based on their `category` or derived f
- **Settings**: User preferences with dynamic/versioned defaults, persisted per user
- **Environment Defaults**: Use function defaults to read runtime context (window, navigator, env)
- **Version Rollouts**: Use `defaultsByInstallVersion` for gradual feature releases
- **API Interaction**: Settings persist to `/settings` endpoint via `storeSetting()`
- **API Interaction**: Settings persist to `/settings` endpoint via `storeSetting()`

66
docs/TEMPLATE_RANKING.md Normal file
View File

@@ -0,0 +1,66 @@
# Template Ranking System
Usage-based ordering for workflow templates with position bias normalization.
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
## Sort Modes
| Mode | Formula | Description |
| -------------- | ------------------------------------------------ | ---------------------- |
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
| `newest` | Date sort | Existing |
| `alphabetical` | Name sort | Existing |
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
## Data Files
**Usage scores** (generated from Mixpanel):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"usage": 1000,
...
}
```
**Search rank** (set per-template in workflow_templates repo):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"searchRank": 8, // Scale 1-10, default 5
...
}
```
| searchRank | Effect |
| ---------- | ---------------------------- |
| 1-4 | Demote (bury in results) |
| 5 | Neutral (default if not set) |
| 6-10 | Promote (boost in results) |
## Position Bias Correction
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
```
correction = 1 + (position - 1) / (maxPosition - 1)
normalizedUsage = rawUsage × correction
```
| Position | Boost |
| -------- | ----- |
| 1 | 1.0× |
| 50 | 1.28× |
| 100 | 1.57× |
| 175 | 2.0× |
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
---

View File

@@ -0,0 +1,58 @@
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize``ComfyMetadataTags.WORKFLOW`, `widget.options.serialize``ComfyMetadataTags.PROMPT`.
## Permutation table
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
## Gotchas
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
## PrimitiveNode and copy/paste
`PrimitiveNode` creates widgets dynamically on connection — it starts as an empty polymorphic node and morphs to match its target widget in `_onFirstConnection()`. This interacts badly with the copy/paste pipeline.
### The clone→serialize gap
`LGraphCanvas._serializeItems()` copies nodes via `item.clone()?.serialize()` (line 3911). For PrimitiveNode this fails:
1. `clone()` calls `this.serialize()` on the **original** node (which has widgets, so `widgets_values` is captured correctly).
2. `clone()` creates a **fresh** PrimitiveNode via `LiteGraph.createNode()` and calls `configure(data)` on it — this stores `widgets_values` on the instance.
3. But the fresh PrimitiveNode has no `this.widgets` (widgets are created only on connection), so when `serialize()` is called on the clone, `LGraphNode.serialize()` skips the `widgets_values` block entirely (line 964: `if (widgets && this.serialize_widgets)`).
Result: `widgets_values` is silently dropped from the clipboard data.
### Why seed survives but control_after_generate doesn't
When the pasted PrimitiveNode reconnects to the pasted target node, `_createWidget()` copies `theirWidget.value` from the target (line 254). This restores the **primary** widget value (e.g., `seed`).
But `control_after_generate` is a **secondary** widget created by `addValueControlWidgets()`, which reads its initial value from `this.widgets_values?.[1]` (line 263). That value was lost during clone→serialize, so it falls back to `'fixed'` (line 265).
See [ADR-0006](adr/0006-primitive-node-copy-paste-lifecycle.md) for proposed fixes and design tradeoffs.
## Code references
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
- Metadata types: `src/types/metadataTypes.ts`
- PrimitiveNode: `src/extensions/core/widgetInputs.ts`
- Copy/paste serialization: `src/lib/litegraph/src/LGraphCanvas.ts` `_serializeItems()`
- Clone: `src/lib/litegraph/src/LGraphNode.ts` `clone()`

View File

@@ -11,11 +11,13 @@ Accepted
ComfyUI's frontend architecture currently depends on a forked version of litegraph.js maintained as a separate package (@comfyorg/litegraph). This separation has created several architectural and operational challenges:
**Architectural Issues:**
- The current split creates a distributed monolith where both packages handle rendering, user interactions, and data models without clear separation of responsibilities
- Both frontend and litegraph manipulate the same data structures, forcing tight coupling across the frontend's data model, views, and business logic
- The lack of clear boundaries prevents implementation of modern architectural patterns like MVC or event-sourcing
**Operational Issues:**
- ComfyUI is the only known user of the @comfyorg/litegraph fork
- Managing separate repositories significantly slows developer velocity due to coordination overhead
- Version mismatches between frontend and litegraph cause recurring issues
@@ -23,6 +25,7 @@ ComfyUI's frontend architecture currently depends on a forked version of litegra
**Future Requirements:**
The following planned features are blocked by the current architecture:
- Multiplayer collaboration requiring CRDT-based state management
- Cloud-based backend support
- Alternative rendering backends
@@ -34,6 +37,7 @@ The following planned features are blocked by the current architecture:
We will merge litegraph.js directly into the ComfyUI frontend repository using git subtree to preserve the complete commit history.
The merge will:
1. Move litegraph source to `src/lib/litegraph/`
2. Update all import paths from `@comfyorg/litegraph` to `@/lib/litegraph`
3. Remove the npm dependency on `@comfyorg/litegraph`
@@ -62,4 +66,4 @@ This integration is the first step toward restructuring the application along cl
- Git subtree was chosen over submodules to provide a cleaner developer experience
- The original litegraph repository will be archived after the merge
- Future litegraph improvements will be made directly in the frontend repository
- Future litegraph improvements will be made directly in the frontend repository

View File

@@ -13,7 +13,7 @@ Proposed
[Most of the context is in here](https://github.com/Comfy-Org/ComfyUI_frontend/issues/4661)
TL;DR: As we're merging more subprojects like litegraph, devtools, and soon a fork of PrimeVue,
a monorepo structure will help a lot with code sharing and organization.
a monorepo structure will help a lot with code sharing and organization.
For more information on Monorepos, check out [monorepo.tools](https://monorepo.tools/)
@@ -37,7 +37,7 @@ There's a [whole list here](https://monorepo.tools/#tools-review) if you're inte
- Adding new projects with shared dependencies becomes really easy
- Makes the process of forking and customizing projects more structured, if not strictly easier
- It *could* speed up the build and development process (not guaranteed)
- It _could_ speed up the build and development process (not guaranteed)
- It would let us cleanly organize and release packages like `comfyui-frontend-types`
### Negative
@@ -47,4 +47,4 @@ There's a [whole list here](https://monorepo.tools/#tools-review) if you're inte
<!-- ## Notes
Optional section for additional information, references, or clarifications. -->
Optional section for additional information, references, or clarifications. -->

View File

@@ -24,7 +24,7 @@ The existing system allows each node to directly mutate its position within Lite
5. **Inefficient Change Detection**: While LiteGraph provides some events, many operations require polling via changeTracker.ts. The current undo/redo system performs expensive diffs on every interaction rather than using reactive push/pull signals, creating performance bottlenecks and blocking efficient animations and viewport culling.
This represents a fundamental architectural limitation: diff-based systems scale O(n) with graph complexity (traverse entire structure to detect changes), while signal-based reactive systems scale O(1) with actual changes (data mutations automatically notify subscribers). Modern frameworks (Vue 3, Angular signals, SolidJS) have moved to reactive approaches for precisely this performance reason.
This represents a fundamental architectural limitation: diff-based systems scale O(n) with graph complexity (traverse entire structure to detect changes), while signal-based reactive systems scale O(1) with actual changes (data mutations automatically notify subscribers). Modern frameworks (Vue 3, Angular signals, SolidJS) have moved to reactive approaches for precisely this performance reason.
### Business Context
@@ -53,12 +53,14 @@ This provides single source of truth, predictable state updates, and natural sys
### Core Architecture
1. **Centralized Layout Store**: A Yjs CRDT maintains all spatial data in a single authoritative store:
```typescript
// Instead of: node.position = {x, y}
layoutStore.moveNode(nodeId, {x, y})
layoutStore.moveNode(nodeId, { x, y })
```
2. **Command Pattern**: All spatial mutations flow through explicit commands:
```
User Input → Commands → Layout Store → Observer Notifications → Renderers
```
@@ -74,12 +76,14 @@ This provides single source of truth, predictable state updates, and natural sys
### Implementation Strategy
**Phase 1: Parallel System**
- Build CRDT layout store alongside existing system
- Layout store initially mirrors LiteGraph changes via observers
- Gradually migrate user interactions to use command interface
- Maintain full backward compatibility
**Phase 2: Inversion of Control**
- CRDT store becomes single source of truth
- LiteGraph receives position updates via reactive subscriptions
- Enable alternative renderers and advanced features
@@ -89,17 +93,20 @@ This provides single source of truth, predictable state updates, and natural sys
This combination provides both architectural and technical benefits:
**Centralized State Benefits:**
- **Single Source of Truth**: All layout data managed in one place, eliminating conflicts
- **System Decoupling**: Rendering, interaction, and layout systems operate independently
- **Predictable Updates**: Clear data flow makes debugging and testing easier
- **Extensibility**: Easy to add new layout behaviors without modifying existing systems
**CRDT Benefits:**
- **Conflict Resolution**: Automatic merging eliminates position conflicts between systems
- **Collaboration-Ready**: Built-in support for multi-user editing
- **Eventual Consistency**: Guaranteed convergence to same state across all clients
**Yjs-Specific Benefits:**
- **Event-Driven**: Native observer pattern removes need for polling
- **Selective Updates**: Only changed nodes trigger system updates
- **Fine-Grained Changes**: Efficient delta synchronization
@@ -109,7 +116,7 @@ This combination provides both architectural and technical benefits:
### Positive
- **Eliminates Polling**: Observer pattern removes O(n) graph traversals, improving performance
- **System Modularity**: Independent systems can be developed, tested, and optimized separately
- **System Modularity**: Independent systems can be developed, tested, and optimized separately
- **Renderer Flexibility**: Easy to add WebGL, DOM accessibility, or hybrid rendering systems
- **Rich Interactions**: Command pattern enables robust undo/redo, macros, and interaction history
- **Collaboration-Ready**: CRDT foundation enables real-time multi-user editing
@@ -140,9 +147,10 @@ This centralized state + CRDT architecture follows patterns from modern collabor
**CRDT in Collaboration**: Tools like Figma, Linear, and Notion use similar approaches for real-time collaboration, demonstrating the effectiveness of separating authoritative data from presentation logic.
**Future Capabilities**: This foundation enables advanced features that would be difficult with the current architecture:
- Macro recording and workflow automation
- Programmatic layout optimization and constraints
- API-driven workflow construction
- API-driven workflow construction
- Multiple simultaneous renderers (canvas + accessibility DOM)
- Real-time collaborative editing
- Advanced spatial features (physics, animations, auto-layout)

View File

@@ -11,22 +11,27 @@ Rejected
ComfyUI's frontend requires modifications to PrimeVue components that cannot be achieved through the library's customization APIs. Two specific technical incompatibilities have been identified with the transform-based canvas architecture:
**Screen Coordinate Hit-Testing Conflicts:**
- PrimeVue components use `getBoundingClientRect()` for screen coordinate calculations that don't account for CSS transforms
- The Slider component directly uses raw `pageX/pageY` coordinates ([lines 102-103](https://github.com/primefaces/primevue/blob/master/packages/primevue/src/slider/Slider.vue#L102-L103)) without transform-aware positioning
- This breaks interaction in transformed coordinate spaces where screen coordinates don't match logical element positions
**Virtual Canvas Scroll Interference:**
- LiteGraph's infinite canvas uses scroll coordinates semantically for graph navigation via the `DragAndScale` coordinate system
- PrimeVue overlay components automatically trigger `scrollIntoView` behavior which interferes with this virtual positioning
- This issue is documented in [PrimeVue discussion #4270](https://github.com/orgs/primefaces/discussions/4270) where the feature request was made to disable this behavior
**Historical Overlay Issues:**
- Previous z-index positioning conflicts required manual workarounds (commit `6d4eafb0`) where PrimeVue Dialog components needed `autoZIndex: false` and custom mask styling, later resolved by removing PrimeVue's automatic z-index management entirely
**Minimal Update Overhead:**
- Analysis of git history shows only 2 PrimeVue version updates in 2+ years, indicating that upstream sync overhead is negligible for this project
**Future Interaction System Requirements:**
- The ongoing canvas architecture evolution will require more granular control over component interaction and event handling as the transform-based system matures
- Predictable need for additional component modifications beyond current identified issues

View File

@@ -0,0 +1,97 @@
# 5. Remove Import Map for Vue Extensions
Date: 2025-12-13
## Status
Accepted
## Context
ComfyUI frontend previously used a Vite plugin (`generateImportMapPlugin`) to inject an HTML import map exposing shared modules to extensions. This allowed Vue-based extensions to mark dependencies as external in their Vite configs:
```typescript
// Extension vite.config.ts (old pattern)
rollupOptions: {
external: ['vue', 'vue-i18n', 'pinia', /^primevue\/?.*/, ...]
}
```
The import map resolved bare specifiers like `import { ref } from 'vue'` at runtime by mapping them to pre-built ESM files served from `/assets/lib/`.
**Modules exposed via import map:**
- `vue` (vue.esm-browser.prod.js)
- `vue-i18n` (vue-i18n.esm-browser.prod.js)
- `primevue/*` (all PrimeVue components)
- `@primevue/themes/*`
- `@primevue/forms/*`
**Problems with import map approach:**
1. **Blocked tree shaking**: Vue and PrimeVue loaded as remote modules at runtime, preventing bundler optimizations. The entire Vue runtime was loaded even if only a few APIs were used.
2. **Poor code splitting**: PrimeVue's component library split into hundreds of small chunks, each requiring a separate network request on mount. This significantly impacted initial page load.
3. **Cold start performance**: Each externalized module required a separate HTTP request and browser module resolution step. This compounded on lower-end systems and slower networks.
4. **Version alignment complexity**: Extensions relied on the frontend's Vue version at runtime. Subtle version mismatches between build-time types and runtime code caused debugging difficulties.
5. **Incompatible with Cloud distribution**: The Cloud deployment model requires fully bundled, optimized assets. Import maps added a layer of indirection incompatible with our CDN and caching strategy.
## Decision
Remove the `generateImportMapPlugin` and require Vue-based extensions to bundle their own Vue instance.
**Implementation (PR #6899):**
- Deleted `build/plugins/generateImportMapPlugin.ts`
- Removed plugin configuration from `vite.config.mts`
- Removed `fast-glob` dependency used by the plugin
**Extension migration path:**
1. Remove `external: ['vue', ...]` from Vite rollup options
2. Vue and related dependencies will be bundled into the extension output
3. No code changes required in extension source files
The import map was already disabled for Cloud builds (PR #6559) before complete removal. Removal aligns all distribution channels on the same bundling strategy.
## Consequences
### Positive
- **Improved page load**: Full tree shaking and optimal code splitting now apply to Vue and PrimeVue
- **Faster development**: No import map generation step; simplified build pipeline
- **Better debugging**: Extension's bundled Vue matches build-time expectations exactly
- **Cloud compatibility**: All assets fully bundled and CDN-optimizable
- **Consistent behavior**: Same bundling strategy across desktop, localhost, and cloud distributions
- **Reduced network requests**: Fewer module fetches on initial page load
### Negative
- **Breaking change for existing extensions**: Extensions using `external: ['vue']` pattern fail with "Failed to resolve module specifier 'vue'" error
- **Larger extension bundles**: Each extension now includes its own Vue instance (~30KB gzipped)
- **Potential version fragmentation**: Different extensions may bundle different Vue versions (mitigated by Vue's stable API)
### Migration Impact
Extensions affected must update their build configuration. The migration is straightforward:
```diff
// vite.config.ts
rollupOptions: {
- external: ['vue', 'vue-i18n', 'primevue', ...]
}
```
Affected versions:
- **v1.32.x - v1.33.8**: Import map present, external pattern works
- **v1.33.9+**: Import map removed, bundling required
## Notes
- [ComfyUI_frontend_vue_basic](https://github.com/jtydhr88/ComfyUI_frontend_vue_basic) has been updated to demonstrate the new bundled pattern
- Issue #7267 documents the user-facing impact and migration discussion
- Future Extension API v2 (Issue #4668) may provide alternative mechanisms for shared dependencies

View File

@@ -0,0 +1,45 @@
# 6. PrimitiveNode Copy/Paste Lifecycle
Date: 2026-02-22
## Status
Proposed
## Context
PrimitiveNode creates widgets dynamically on connection. When copied, the clone has no `this.widgets`, so `LGraphNode.serialize()` drops `widgets_values` from the clipboard data. This causes secondary widget values (e.g., `control_after_generate`) to be lost on paste. See [WIDGET_SERIALIZATION.md](../WIDGET_SERIALIZATION.md#primitiveno-and-copypaste) for the full mechanism.
Related: [#1757](https://github.com/Comfy-Org/ComfyUI_frontend/issues/1757), [#8938](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8938)
## Options
### A. Minimal fix: override `serialize()` on PrimitiveNode
Override `serialize()` to fall back to `this.widgets_values` (set during `configure()`) when the base implementation omits it due to missing `this.widgets`.
- **Pro**: No change to connection lifecycle semantics. Lowest risk.
- **Pro**: Doesn't affect workflow save/load (which already works via `onAfterGraphConfigured`).
- **Con**: Doesn't address the deeper design issue — primitives are still empty on copy.
### B. Clone-configured-instance lifecycle
On copy, the primitive is a clone of the configured instance (with widgets intact). On disconnect or paste without connections, it returns to empty state.
- **Pro**: Copy→serialize captures `widgets_values` correctly. Matches OOP expectations.
- **Pro**: Secondary widget state survives round-trips without special-casing.
- **Con**: `input.widget[CONFIG]` allows extensions to make PrimitiveNode create a _different_ widget than the target. Widget config is derived at connection time, not stored, so cloning the configured state may not be faithful.
- **Con**: Deserialization ordering — `configure()` runs before links are restored. PrimitiveNode needs links to know what widgets to create. `onAfterGraphConfigured()` handles this for workflow load, but copy/paste uses a different code path.
- **Con**: Higher risk of regressions in extension compatibility.
### C. Projection model (like Subgraph widgets)
Primitives act as a synchronization mechanism — no own state, just a projection of the target widget's resolved value.
- **Pro**: Cleanest conceptual model. Eliminates state duplication.
- **Con**: Primitives can connect to multiple targets. Projection with multiple targets is ambiguous.
- **Con**: Major architectural change with broad impact.
## Decision
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.

View File

@@ -8,12 +8,13 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
## Creating a New ADR

View File

@@ -0,0 +1,91 @@
# Change Tracker (Undo/Redo System)
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
history by comparing serialized graph snapshots.
## How It Works
`checkState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `checkState()` is explicitly triggered.
## Automatic Triggers
These are set up once in `ChangeTracker.init()`:
| Trigger | Event / Hook | What It Catches |
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
| Mouse click | `window` `mouseup` | General clicks on native DOM |
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `checkState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
- **Vue-rendered widgets** — Vue handles events internally without triggering
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
dropdown doesn't bubble the same way as a native LiteGraph widget click)
- **Programmatic graph mutations** — Any code that modifies the graph outside
of user interaction (e.g., applying a template, pasting nodes, aligning)
- **Async operations** — File uploads, API calls that change widget values
after the initial user gesture
### Pattern for Manual Calls
```typescript
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
```
### Existing Manual Call Sites
These locations already call `checkState()` explicitly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `useAppSetDefaultView.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `workflowService.ts` — After workflow service operations
## Transaction Guards
For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls checkState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
`after-change` sub-types.
## Key Invariants
- `checkState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

View File

@@ -11,7 +11,6 @@ Extensions are the primary way to add functionality to ComfyUI. They can be cust
- How extensions load (backend vs frontend)
- Why extensions don't work in dev server
- Development workarounds and best practices
- **[Core Extensions Reference](./core.md)** - Detailed reference for core extensions:
- Complete list of all core extensions
- Extension architecture principles
@@ -42,4 +41,4 @@ Extensions are the primary way to add functionality to ComfyUI. They can be cust
- Check the [Development Guide](./development.md) for common issues
- Review [Core Extensions](./core.md) for examples
- Visit the [ComfyUI Discord](https://discord.com/invite/comfyorg) for community support
- Visit the [ComfyUI Discord](https://discord.com/invite/comfyorg) for community support

View File

@@ -29,57 +29,58 @@ The following table lists ALL core extensions in the system as of 2025-01-30:
### Main Extensions
| Extension | Description | Category |
|-----------|-------------|----------|
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
| editAttention.ts | Implements attention editing functionality | Text |
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
| groupNodeManage.ts | Provides group node management operations | Graph |
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskEditorOld.ts | Legacy mask editor implementation | Image |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
| previewAny.ts | Universal preview functionality for various data types | Preview |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
| slotDefaults.ts | Manages default values for node slots | Nodes |
| uploadAudio.ts | Handles audio file upload functionality | Audio |
| uploadImage.ts | Handles image upload functionality | Image |
| webcamCapture.ts | Provides webcam capture capabilities | Media |
| widgetInputs.ts | Implements various widget input types | Widgets |
| Extension | Description | Category |
| ----------------------- | ------------------------------------------------------------- | --------- |
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
| editAttention.ts | Implements attention editing functionality | Text |
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
| groupNodeManage.ts | Provides group node management operations | Graph |
| groupOptions.ts | Handles group node configuration options | Graph |
| index.ts | Main extension registration and coordination | Core |
| load3d.ts | Supports 3D model loading and visualization | 3D |
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
| nodeTemplates.ts | Provides node template functionality | Templates |
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
| previewAny.ts | Universal preview functionality for various data types | Preview |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
| slotDefaults.ts | Manages default values for node slots | Nodes |
| uploadAudio.ts | Handles audio file upload functionality | Audio |
| uploadImage.ts | Handles image upload functionality | Image |
| webcamCapture.ts | Provides webcam capture capabilities | Media |
| widgetInputs.ts | Implements various widget input types | Widgets |
### Conditional Lines Subdirectory
Located in `extensions/core/load3d/conditional-lines/`:
| File | Description |
|------|-------------|
| ColoredShadowMaterial.js | Material for colored shadow rendering |
| File | Description |
| --------------------------- | --------------------------------------- |
| ColoredShadowMaterial.js | Material for colored shadow rendering |
| ConditionalEdgesGeometry.js | Geometry for conditional edge rendering |
| ConditionalEdgesShader.js | Shader for conditional edges |
| OutsideEdgesGeometry.js | Geometry for outside edge detection |
| ConditionalEdgesShader.js | Shader for conditional edges |
| OutsideEdgesGeometry.js | Geometry for outside edge detection |
### Lines2 Subdirectory
### Lines2 Subdirectory
Located in `extensions/core/load3d/conditional-lines/Lines2/`:
| File | Description |
|------|-------------|
| ConditionalLineMaterial.js | Material for conditional line rendering |
| ConditionalLineSegmentsGeometry.js | Geometry for conditional line segments |
| File | Description |
| ---------------------------------- | --------------------------------------- |
| ConditionalLineMaterial.js | Material for conditional line rendering |
| ConditionalLineSegmentsGeometry.js | Geometry for conditional line segments |
### ThreeJS Override Subdirectory
Located in `extensions/core/load3d/threejsOverride/`:
| File | Description |
|------|-------------|
| File | Description |
| -------------------- | --------------------------------------------- |
| OverrideMTLLoader.js | Custom MTL loader with enhanced functionality |
## Extension Development
@@ -98,19 +99,19 @@ Extensions are registered using the `app.registerExtension()` method:
```javascript
app.registerExtension({
name: "MyExtension",
name: 'MyExtension',
// Hook implementations
async init() {
// Implementation
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Implementation
}
// Other hooks as needed
});
})
```
## Extension Hooks
@@ -152,18 +153,18 @@ nodeCreated
### Key Hooks
| Hook | Description |
|------|-------------|
| `init` | Called after canvas creation but before nodes are added |
| `setup` | Called after the application is fully set up and running |
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
| `getCustomWidgets` | Allows extensions to add custom widgets |
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
| `registerCustomNodes` | Allows extensions to register additional nodes |
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
| `nodeCreated` | Called after a node's constructor |
| `beforeConfigureGraph` | Called before a graph is configured |
| `afterConfigureGraph` | Called after a graph is configured |
| Hook | Description |
| ----------------------------- | ---------------------------------------------------------- |
| `init` | Called after canvas creation but before nodes are added |
| `setup` | Called after the application is fully set up and running |
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
| `getCustomWidgets` | Allows extensions to add custom widgets |
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
| `registerCustomNodes` | Allows extensions to register additional nodes |
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
| `nodeCreated` | Called after a node's constructor |
| `beforeConfigureGraph` | Called before a graph is configured |
| `afterConfigureGraph` | Called after a graph is configured |
| `getSelectionToolboxCommands` | Allows extensions to add commands to the selection toolbox |
For the complete list of available hooks and detailed descriptions, see the [ComfyExtension interface in comfy.ts](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts).
@@ -178,4 +179,4 @@ For more detailed information about ComfyUI's extension system, refer to the off
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.

View File

@@ -19,6 +19,7 @@
### How Extensions Load
**Backend Flow (Python Custom Nodes):**
1. ComfyUI server starts → scans `/custom_nodes/` directories
2. Loads Python modules (e.g., `/custom_nodes/ComfyUI-Impact-Pack/__init__.py`)
3. Python code registers new node types with the server
@@ -27,12 +28,14 @@
**Frontend Flow (JavaScript):**
*Core Extensions (always available):*
_Core Extensions (always available):_
1. Built directly into the frontend bundle at `/src/extensions/core/`
2. Loaded immediately when the frontend starts
3. No network requests needed - they're part of the compiled code
*Custom Node JavaScript (loaded dynamically):*
_Custom Node JavaScript (loaded dynamically):_
1. Frontend starts → calls `/extensions` API
2. Server responds with list of JavaScript files from:
- `/web/extensions/*.js` (legacy location)
@@ -42,6 +45,7 @@
5. These registered hooks enhance the UI for their associated Python nodes
**The Key Distinction:**
- **Python nodes** = Backend processing (what shows in your node menu)
- **JavaScript extensions** = Frontend enhancements (how nodes look/behave in the UI)
- A custom node package can have both, just Python, or (rarely) just JavaScript
@@ -58,6 +62,7 @@ ComfyUI migrated to TypeScript and Vite, but thousands of extensions rely on the
**Production Build:**
During production build, a custom Vite plugin:
- Binds all module exports to `window.comfyAPI`
- Generates shim files that re-export from this global object
@@ -73,6 +78,7 @@ import { api } from '/scripts/api.js'
```
**Why Dev Server Can't Support This:**
- The dev server serves raw source files without bundling
- Vite refuses to transform node_modules in unbundled mode
- Creating real-time shims would require intercepting every module request
@@ -81,6 +87,7 @@ import { api } from '/scripts/api.js'
### The Trade-off
This was the least friction approach:
- ✅ Extensions work in production without changes
- ✅ Developers get modern tooling (TypeScript, hot reload)
- ❌ Extension testing requires production build or workarounds
@@ -104,11 +111,13 @@ The alternative would have been breaking all existing extensions or staying with
### Option 2: Use Production Build
Build the frontend for full functionality:
```bash
pnpm build
```
For faster iteration during development, use watch mode:
```bash
pnpm exec vite build --watch
```
@@ -118,6 +127,7 @@ Note: Watch mode provides faster rebuilds than full builds, but still no hot rel
### Option 3: Test Against Cloud/Staging
For cloud extensions, modify `.env`:
```
DEV_SERVER_COMFYUI_URL=http://stagingcloud.comfy.org/
```
@@ -133,4 +143,4 @@ DEV_SERVER_COMFYUI_URL=http://stagingcloud.comfy.org/
- [Core Extensions Architecture](./core.md) - Complete list of core extensions and development guidelines
- [JavaScript Extension Hooks](https://docs.comfy.org/custom-nodes/js/javascript_hooks) - Official documentation on extension hooks
- [ComfyExtension Interface](../../src/types/comfy.ts) - TypeScript interface defining all extension capabilities
- [ComfyExtension Interface](../../src/types/comfy.ts) - TypeScript interface defining all extension capabilities

View File

@@ -0,0 +1,95 @@
---
globs:
- '**/*.spec.ts'
---
# Playwright E2E Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Best Practices
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
- Prefer specific selectors (role, label, test-id)
- Test across viewports
## Window Globals
Browser tests access `window.app`, `window.graph`, and `window.LiteGraph` which are
optional in the main app types. In E2E tests, use non-null assertions (`!`):
```typescript
window.app!.graph!.nodes
window.LiteGraph!.registered_node_types
```
This is the **only context** where non-null assertions are acceptable.
**TODO:** Consolidate these references into a central utility (e.g., `getApp()`) that
performs proper runtime type checking, removing the need for scattered `!` assertions.
## Type Assertions in E2E Tests
E2E tests may use **specific** type assertions when needed, but **never** `as any`.
### Acceptable Patterns
```typescript
// ✅ Non-null assertions for window globals
window.app!.extensionManager
// ✅ Specific type assertions with documentation
// Extensions can register arbitrary setting IDs
id: 'TestSetting' as TestSettingId
// ✅ Test-local type helpers
type TestSettingId = keyof Settings
```
### Forbidden Patterns
```typescript
// ❌ Never use `as any`
settings: testData as any
// ❌ Never modify production types to satisfy test errors
// Don't add test settings to src/schemas/apiSchema.ts
// ❌ Don't chain through unknown to bypass types
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
```
### Accessing Internal State
When tests need internal store properties (e.g., `.workflow`, `.focusMode`):
```typescript
// ✅ Access stores directly in page.evaluate
await page.evaluate(() => {
const store = useWorkflowStore()
return store.activeWorkflow
})
// ❌ Don't change public API types to expose internals
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
```
## Test Tags
Tags are respected by config:
- `@mobile` - Mobile viewport tests
- `@2x` - High DPI tests
## Test Data
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
## Running Tests
```bash
pnpm test:browser:local # Run all E2E tests
pnpm test:browser:local -- --ui # Interactive UI mode
```

View File

@@ -0,0 +1,59 @@
---
globs:
- '**/*.stories.ts'
---
# Storybook Conventions
## File Placement
Place `*.stories.ts` files alongside their components:
```
src/components/MyComponent/
├── MyComponent.vue
└── MyComponent.stories.ts
```
## Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
/* props */
}
}
```
## Required Story Variants
Include when applicable:
- **Default** - Minimal props
- **WithData** - Realistic data
- **Loading** - Loading state
- **Error** - Error state
- **Empty** - No data
## Mock Data
Use realistic ComfyUI schemas for mocks (node definitions, components).
## Running Storybook
```bash
pnpm storybook # Development server
pnpm build-storybook # Production build
```

View File

@@ -0,0 +1,78 @@
---
globs:
- '**/*.ts'
- '**/*.tsx'
- '**/*.vue'
---
# TypeScript Conventions
## Type Safety
- Never use `any` type - use proper TypeScript types
- Never use `as any` type assertions - fix the underlying type issue
- Type assertions are a last resort; they lead to brittle code
- Avoid `@ts-expect-error` - fix the underlying issue instead
### Type Assertion Hierarchy
When you must handle uncertain types, prefer these approaches in order:
1.**No assertion** — Properly typed from the start
2.**Type narrowing**`if ('prop' in obj)` or type guards
3. ⚠️ **Specific assertion**`as SpecificType` when you truly know the type
4. ⚠️ **`unknown` with narrowing** — For genuinely unknown data
5.**`as any`** — FORBIDDEN
### Zod Schema Rules
- Never use `z.any()` — it disables validation and propagates `any` into types
- Use `z.unknown()` if the type is genuinely unknown, then narrow it
- Never add test-only settings/types to production schemas
### Public API Contracts
- Keep public API types stable (e.g., `ExtensionManager` interface)
- Don't expose internal implementation types (e.g., Pinia store internals)
- Reactive refs (`ComputedRef<T>`) should be unwrapped before exposing
## Avoiding Circular Dependencies
Extract type guards and their associated interfaces into **leaf modules** — files with only `import type` statements. This keeps them safe to import from anywhere without pulling in heavy transitive dependencies.
```typescript
// ✅ myTypes.ts — leaf module (only type imports)
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface MyView extends IBaseWidget {
/* ... */
}
export function isMyView(w: IBaseWidget): w is MyView {
return 'myProp' in w
}
// ❌ myView.ts — heavy module (runtime imports from stores, utils, etc.)
// Importing the type guard from here drags in the entire dependency tree.
```
## Utility Libraries
- Use `es-toolkit` for utility functions (not lodash)
## API Utilities
When making API calls in `src/`:
```typescript
// ✅ Correct - use api helpers
const response = await api.get(api.apiURL('/prompt'))
const template = await fetch(api.fileURL('/templates/default.json'))
// ❌ Wrong - direct URL construction
const response = await fetch('/api/prompt')
```
## Security
- Sanitize HTML with `DOMPurify.sanitize()`
- Never log secrets or sensitive data

36
docs/guidance/vitest.md Normal file
View File

@@ -0,0 +1,36 @@
---
globs:
- '**/*.test.ts'
---
# Vitest Unit Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Test Quality
- Do not write change detector tests (tests that just assert defaults)
- Do not write tests dependent on non-behavioral features (styles, classes)
- Do not write tests that just test mocks - ensure real code is exercised
- Be parsimonious; avoid redundant tests
## Mocking
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
- Keep module mocks contained - no global mutable state
- Use `vi.hoisted()` for per-test mock manipulation
- Don't mock what you don't own
## Component Testing
- Use Vue Test Utils for component tests
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
```

View File

@@ -0,0 +1,50 @@
---
globs:
- '**/*.vue'
---
# Vue Component Conventions
Applies to all `.vue` files anywhere in the codebase.
## Vue 3 Composition API
- Use `<script setup lang="ts">` for component logic
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
- Use `ref`/`reactive` for state
- Use `computed()` for derived state
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
## Component Communication
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Proper props and emits definitions
## VueUse Composables
Prefer VueUse composables over manual event handling:
- `useElementHover` instead of manual mouseover/mouseout listeners
- `useIntersectionObserver` for visibility detection instead of scroll handlers
- `useFocusTrap` for modal/dialog focus management
- `useEventListener` for auto-cleanup event listeners
Prefer Vue native options when available:
- `defineModel` instead of `useVModel` for two-way binding with props
## Styling
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
- Exception: when third-party libraries render runtime DOM outside Vue templates
(for example xterm internals inside PrimeVue terminal wrappers), scoped
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
exception is required.
## Best Practices
- Extract complex conditionals to `computed`
- In unmounted hooks, implement cleanup for async operations

62
docs/release-process.md Normal file
View File

@@ -0,0 +1,62 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

50
docs/testing/README.md Normal file
View File

@@ -0,0 +1,50 @@
# ComfyUI Frontend Testing Guide
This guide provides an overview of testing approaches used in the ComfyUI Frontend codebase. These guides are meant to document any particularities or nuances of writing tests in this codebase, rather than being a comprehensive guide to testing in general. By reading these guides first, you may save yourself some time when encountering issues.
## Testing Documentation
Documentation for unit tests is organized into three guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
## Testing Structure
The ComfyUI Frontend project uses **colocated tests** - test files are placed alongside their source files:
- **Component Tests**: Located directly alongside their components (e.g., `MyComponent.test.ts` next to `MyComponent.vue`)
- **Unit Tests**: Located alongside their source files (e.g., `myUtil.test.ts` next to `myUtil.ts`)
- **Store Tests**: Located in `src/stores/` alongside their store files
- **Browser Tests**: Located in the `browser_tests/` directory (see dedicated README there)
### Test File Naming
- Use `.test.ts` extension for test files
- Name tests after their source file: `sourceFile.test.ts`
## Test Frameworks and Libraries
Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started
To run the tests locally:
```bash
# Run unit tests
pnpm test:unit
# Run a specific test file
pnpm test:unit -- src/path/to/file.test.ts
# Run unit tests in watch mode
pnpm test:unit -- --watch
```
Refer to the specific guides for more detailed information on each testing type.

View File

@@ -0,0 +1,371 @@
# Component Testing Guide
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents
1. [Basic Component Testing](#basic-component-testing)
2. [PrimeVue Components Testing](#primevue-components-testing)
3. [Tooltip Directives](#tooltip-directives)
4. [Component Events Testing](#component-events-testing)
5. [User Interaction Testing](#user-interaction-testing)
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe('SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false
}
const mountSidebarIcon = (props, options = {}) => {
return mount(SidebarIcon, {
props: { ...exampleProps, ...props },
...options
})
}
it('renders label', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('.p-button.p-component').exists()).toBe(true)
expect(wrapper.find('.p-button-label').exists()).toBe(true)
})
})
```
## PrimeVue Components Testing
Setting up and testing PrimeVue components:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { createApp } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
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: [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
],
...props
}
})
}
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'
})
})
})
```
## Tooltip Directives
Testing components with tooltip directives:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
describe('SidebarIcon with tooltip', () => {
it('shows tooltip on hover', async () => {
const tooltipShowDelay = 300
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
// Hover over the icon
await wrapper.trigger('mouseenter')
await new Promise((resolve) => setTimeout(resolve, tooltipShowDelay + 16))
const tooltipElAfterHover = document.querySelector('[role="tooltip"]')
expect(tooltipElAfterHover).not.toBeNull()
})
it('sets aria-label attribute when tooltip is provided', () => {
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
expect(wrapper.attributes('aria-label')).toEqual(tooltipText)
})
})
```
## Component Events Testing
Testing component events:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
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'])
})
```
## User Interaction Testing
Testing user interactions:
```typescript
// Example from: src/components/common/EditableText.spec.ts
describe('EditableText', () => {
it('switches to edit mode on click', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Initially in view mode
expect(wrapper.find('input').exists()).toBe(false)
// Click to edit
await wrapper.find('.editable-text').trigger('click')
// Should switch to edit mode
expect(wrapper.find('input').exists()).toBe(true)
expect(wrapper.find('input').element.value).toBe('Initial Text')
})
it('saves changes on enter key press', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Switch to edit mode
await wrapper.find('.editable-text').trigger('click')
// Change input value
const input = wrapper.find('input')
await input.setValue('New Text')
// Press enter to save
await input.trigger('keydown.enter')
// Check if event was emitted with new value
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['New Text'])
// Should switch back to view mode
expect(wrapper.find('input').exists()).toBe(false)
})
})
```
## Asynchronous Component Testing
Testing components with async behavior:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
import { nextTick } from 'vue'
it('shows dropdown options when clicked', async () => {
const wrapper = mount(PackVersionSelectorPopover, {
props: {
versions: ['1.0.0', '1.1.0', '2.0.0'],
selectedVersion: '1.1.0'
}
})
// Initially dropdown should be hidden
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(false)
// Click dropdown
await wrapper.find('.p-dropdown').trigger('click')
await nextTick() // Wait for Vue to update the DOM
// Dropdown should be visible now
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(true)
// Options should match the provided versions
const options = wrapper.findAll('.p-dropdown-item')
expect(options.length).toBe(3)
expect(options[0].text()).toBe('1.0.0')
expect(options[1].text()).toBe('1.1.0')
expect(options[2].text()).toBe('2.0.0')
})
```
## Working with Vue Reactivity
Testing components with complex reactive behavior can be challenging. Here are patterns to help manage reactivity issues in tests:
### Helper Function for Waiting on Reactivity
Use a helper function to wait for both promises and the Vue reactivity cycle:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
const waitForPromises = async () => {
// Wait for any promises in the microtask queue
await new Promise((resolve) => setTimeout(resolve, 16))
// Wait for Vue to update the DOM
await nextTick()
}
it('fetches versions on mount', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises() // Wait for async operations and reactivity
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
```
### Testing Components with Async Lifecycle Hooks
When components use `onMounted` or other lifecycle hooks with async operations:
```typescript
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
// Check loading state before promises resolve
expect(wrapper.text()).toContain('Loading versions...')
})
```
### Testing Prop Changes
Test components' reactivity to prop changes:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
```
### Handling Computed Properties
Testing components with computed properties that depend on async data:
```typescript
it('displays special options and version options in the listbox', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises() // Wait for data fetching and computed property updates
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
// Now options should be populated through computed properties
expect(options.length).toBe(defaultMockVersions.length + 2)
})
```
### Common Reactivity Pitfalls
1. **Not waiting for all promises**: Ensure you wait for both component promises and Vue's reactivity system
2. **Timing issues with component mounting**: Components might not be fully mounted when assertions run
3. **Async lifecycle hooks**: Components using async `onMounted` require careful handling
4. **PrimeVue components**: PrimeVue components often have their own internal state and reactivity that needs time to update
5. **Computed properties depending on async data**: Always ensure async data is loaded before testing computed properties
By using the `waitForPromises` helper and being mindful of these patterns, you can write more robust tests for components with complex reactivity.

View File

@@ -0,0 +1,284 @@
# Pinia Store Testing Guide
This guide covers patterns and examples for testing Pinia stores in the ComfyUI Frontend codebase.
## Table of Contents
1. [Setting Up Store Tests](#setting-up-store-tests)
2. [Testing Store State](#testing-store-state)
3. [Testing Store Actions](#testing-store-actions)
4. [Testing Store Getters](#testing-store-getters)
5. [Mocking Dependencies in Stores](#mocking-dependencies-in-stores)
6. [Testing Store Watchers](#testing-store-watchers)
7. [Testing Store Integration](#testing-store-integration)
## Setting Up Store Tests
Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/domains/workflow/ui/stores/workflowStore'
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh testing pinia and activate it for each test
setActivePinia(createTestingPinia({ stubActions: false }))
// Initialize the store
store = useWorkflowStore()
// Clear any mocks
vi.clearAllMocks()
})
it('should initialize with default state', () => {
expect(store.workflows).toEqual([])
expect(store.activeWorkflow).toBeUndefined()
expect(store.openWorkflows).toEqual([])
})
})
```
## Testing Store State
Testing store state changes:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
const workflow2 = store.createTemporary()
expect(workflow2.path).toBe('workflows/Unsaved Workflow (2).json')
})
it('should create a temporary workflow not clashing with persisted workflows', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
```
## Testing Store Actions
Testing store actions:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
const workflow = store.createTemporary('test.json')
const mockWorkflowData = { nodes: [], links: [] }
// Mock the load response
vi.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: mockWorkflowData } as any
return workflow as LoadedComfyWorkflow
})
// Open the workflow
await store.openWorkflow(workflow)
// Verify the workflow is now active
expect(store.activeWorkflow?.path).toBe(workflow.path)
// Verify the workflow is in the open workflows list
expect(store.isOpen(workflow)).toBe(true)
})
it('should not reload an already active workflow', async () => {
const workflow = await store.createTemporary('test.json').load()
vi.spyOn(workflow, 'load')
// Set as active workflow
store.activeWorkflow = workflow
await store.openWorkflow(workflow)
// Verify load was not called
expect(workflow.load).not.toHaveBeenCalled()
})
})
```
## Testing Store Getters
Testing store getters:
```typescript
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
store = useModelStore()
// Set up test data
store.models = {
checkpoints: [
{
name: 'model1.safetensors',
path: 'models/checkpoints/model1.safetensors'
},
{ name: 'model2.ckpt', path: 'models/checkpoints/model2.ckpt' }
],
loras: [
{ name: 'lora1.safetensors', path: 'models/loras/lora1.safetensors' }
]
}
// Mock API
vi.mocked(api.getModelInfo).mockImplementation(async (modelName) => {
if (modelName.includes('model1')) {
return { info: { resolution: 768 } }
}
return { info: { resolution: 512 } }
})
})
it('should return models grouped by type', () => {
expect(store.modelsByType.checkpoints.length).toBe(2)
expect(store.modelsByType.loras.length).toBe(1)
})
it('should filter models by name', () => {
store.searchTerm = 'model1'
expect(store.filteredModels.checkpoints.length).toBe(1)
expect(store.filteredModels.checkpoints[0].name).toBe('model1.safetensors')
})
})
```
## Mocking Dependencies in Stores
Mocking API and other dependencies:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: null // Start with canvas potentially undefined or null
}
}))
describe('syncWorkflows', () => {
const syncRemoteWorkflows = async (filenames: string[]) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
filenames.map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
return await store.syncWorkflows()
}
it('should sync workflows', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(2)
})
})
```
## Testing Store Watchers
Testing store watchers and reactive behavior:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
it('should update automatically when activeWorkflow changes', async () => {
// Arrange: Set initial canvas state
const initialSubgraph = {
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
}
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
// Trigger initial update
store.updateActiveGraph()
await nextTick()
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
// Act: Change the active workflow and canvas state
const workflow2 = store.createTemporary('workflow2.json')
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
workflow2.changeTracker = { activeState: {} } as any
workflow2.originalContent = '{}'
workflow2.content = '{}'
return workflow2 as LoadedComfyWorkflow
})
// Change canvas state
vi.mocked(comfyApp.canvas).subgraph = undefined
await store.openWorkflow(workflow2)
await nextTick() // Allow watcher to trigger
// Assert: Check state was updated by the watcher
expect(store.isSubgraphActive).toBe(false)
expect(store.subgraphNamePath).toEqual([])
})
})
```
## Testing Store Integration
Testing store integration with other parts of the application:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
const bookmarkStore = useWorkflowBookmarkStore()
// Set up initial bookmark
expect(workflow.path).toBe('workflows/dir/test.json')
await bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Mock super.rename
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
async function (this: any, newPath: string) {
this.path = newPath
return this
} as any
)
// Perform rename
const newPath = 'workflows/dir/renamed.json'
await store.renameWorkflow(workflow, newPath)
// Check that bookmark was transferred
expect(bookmarkStore.isBookmarked(newPath)).toBe(true)
expect(bookmarkStore.isBookmarked('workflows/dir/test.json')).toBe(false)
})
})
```

View File

@@ -0,0 +1,332 @@
# Unit Testing Guide
This guide covers patterns and examples for unit testing utilities, composables, and other non-component code in the ComfyUI Frontend codebase.
## Table of Contents
1. [Testing Vue Composables with Reactivity](#testing-vue-composables-with-reactivity)
2. [Working with LiteGraph and Nodes](#working-with-litegraph-and-nodes)
3. [Working with Workflow JSON Files](#working-with-workflow-json-files)
4. [Mocking the API Object](#mocking-the-api-object)
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
Testing Vue composables requires handling reactivity correctly:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
subscribeLogs: vi.fn()
}
}))
describe('useServerLogs', () => {
it('should update reactive logs when receiving events', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
// Simulate log event handler being called
const mockHandler = vi.mocked(useEventListener).mock.calls[0][2]
mockHandler(
new CustomEvent('logs', {
detail: {
type: 'logs',
entries: [{ m: 'Log message' }]
}
})
)
// Must wait for Vue reactivity to update
await nextTick()
expect(logs.value).toEqual(['Log message'])
})
})
```
## Working with LiteGraph and Nodes
Testing LiteGraph-related functionality:
```typescript
// Example from: tests-ui/tests/litegraph.test.ts
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
import { describe, expect, it } from 'vitest'
// Create dummy node for testing
class DummyNode extends LGraphNode {
constructor() {
super('dummy')
}
}
describe('LGraph', () => {
it('should serialize graph nodes', async () => {
// Register node type
LiteGraph.registerNodeType('dummy', DummyNode)
// Create graph with nodes
const graph = new LGraph()
const node = new DummyNode()
graph.add(node)
// Test serialization
const result = graph.serialize()
expect(result.nodes).toHaveLength(1)
expect(result.nodes[0].type).toBe('dummy')
})
})
```
## Working with Workflow JSON Files
Testing with ComfyUI workflow files:
```typescript
// Example from: tests-ui/tests/comfyWorkflow.test.ts
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/domains/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
describe('workflow validation', () => {
it('should validate default workflow', async () => {
const validWorkflow = JSON.parse(JSON.stringify(defaultGraph))
// Validate workflow
const result = await validateComfyWorkflow(validWorkflow)
expect(result).not.toBeNull()
})
it('should handle position format conversion', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
// Legacy position format as object
workflow.nodes[0].pos = { '0': 100, '1': 200 }
// Should convert to array format
const result = await validateComfyWorkflow(workflow)
expect(result.nodes[0].pos).toEqual([100, 200])
})
})
```
## Mocking the API Object
Mocking the ComfyUI API object:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
// Mock the api object
vi.mock('@/scripts/api', () => ({
api: {
subscribeLogs: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
it('should subscribe to logs API', () => {
// Call function that uses the API
startListening()
// Verify API was called correctly
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
```
## Mocking Lodash Functions
Mocking utility functions like debounce:
```typescript
// Mock debounce to execute immediately
import { debounce } from 'es-toolkit/compat'
vi.mock('es-toolkit/compat', () => ({
debounce: vi.fn((fn) => {
// Return function that calls the input function immediately
const mockDebounced = (...args: any[]) => fn(...args)
// Add cancel method that debounced functions have
mockDebounced.cancel = vi.fn()
return mockDebounced
})
}))
describe('Function using debounce', () => {
it('calls debounced function immediately in tests', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 1000)
debouncedFn()
// No need to wait - our mock makes it execute immediately
expect(mockFn).toHaveBeenCalled()
})
})
```
## Testing with Debounce and Throttle
When you need to test real debounce/throttle behavior:
```typescript
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced function', () => {
beforeEach(() => {
vi.useFakeTimers() // Use fake timers to control time
})
afterEach(() => {
vi.useRealTimers()
})
it('should debounce function calls', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 1000)
// Call multiple times
debouncedFn()
debouncedFn()
debouncedFn()
// Function not called yet (debounced)
expect(mockFn).not.toHaveBeenCalled()
// Advance time just before debounce period
vi.advanceTimersByTime(999)
expect(mockFn).not.toHaveBeenCalled()
// Advance to debounce completion
vi.advanceTimersByTime(1)
expect(mockFn).toHaveBeenCalledTimes(1)
})
})
```
## Mocking Node Definitions
Creating mock node definitions for testing:
```typescript
// Example from: tests-ui/tests/apiTypes.test.ts
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,
validateComfyNodeDef
} from '@/schemas/nodeDefSchema'
// Create a complete mock node definition
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
input: {
required: {
ckpt_name: [['model1.safetensors', 'model2.ckpt'], {}]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: '',
python_module: 'nodes',
category: 'loaders',
output_node: false,
experimental: false,
deprecated: false
}
it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

View File

@@ -0,0 +1,142 @@
---
globs:
- '**/*.test.ts'
- '**/*.spec.ts'
---
# Vitest Patterns
## Setup
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
```typescript
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('MyStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.resetAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
})
```
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns
### Reset all mocks at once
```typescript
beforeEach(() => {
vi.resetAllMocks() // Not individual mock.mockReset() calls
})
```
### Module mocks with vi.mock()
```typescript
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
fetchData: vi.fn()
}
}))
vi.mock('@/services/myService', () => ({
myService: {
doThing: vi.fn()
}
}))
```
### Configure mocks in tests
```typescript
import { api } from '@/scripts/api'
import { myService } from '@/services/myService'
it('handles success', () => {
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
// ... test code
})
```
## Testing Event Listeners
When a store registers event listeners at module load time:
```typescript
function getEventHandler() {
const call = vi
.mocked(api.addEventListener)
.mock.calls.find(([event]) => event === 'my_event')
return call?.[1] as (e: CustomEvent<MyEventType>) => void
}
function dispatch(data: MyEventType) {
const handler = getEventHandler()
handler(new CustomEvent('my_event', { detail: data }))
}
it('handles events', () => {
const store = useMyStore()
dispatch({ field: 'value' })
expect(store.items).toHaveLength(1)
})
```
## Testing with Fake Timers
For stores with intervals, timeouts, or polling:
```typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('polls after delay', async () => {
const store = useMyStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(30000)
expect(mockService.fetch).toHaveBeenCalled()
})
```
## Assertion Style
Prefer `.toHaveLength()` over `.length.toBe()`:
```typescript
// Good
expect(store.items).toHaveLength(1)
// Avoid
expect(store.items.length).toBe(1)
```
Use `.toMatchObject()` for partial matching:
```typescript
expect(store.completedItems[0]).toMatchObject({
id: 'task-123',
status: 'done'
})
```