mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 09:27:41 +00:00
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:
@@ -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)
|
||||
})
|
||||
```
|
||||
|
||||
@@ -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
66
docs/TEMPLATE_RANKING.md
Normal 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.
|
||||
|
||||
---
|
||||
58
docs/WIDGET_SERIALIZATION.md
Normal file
58
docs/WIDGET_SERIALIZATION.md
Normal 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()`
|
||||
@@ -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
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
97
docs/adr/0005-remove-importmap-for-vue-extensions.md
Normal file
97
docs/adr/0005-remove-importmap-for-vue-extensions.md
Normal 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
|
||||
45
docs/adr/0006-primitive-node-copy-paste-lifecycle.md
Normal file
45
docs/adr/0006-primitive-node-copy-paste-lifecycle.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
91
docs/architecture/change-tracker.md
Normal file
91
docs/architecture/change-tracker.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
95
docs/guidance/playwright.md
Normal file
95
docs/guidance/playwright.md
Normal 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
|
||||
```
|
||||
59
docs/guidance/storybook.md
Normal file
59
docs/guidance/storybook.md
Normal 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
|
||||
```
|
||||
78
docs/guidance/typescript.md
Normal file
78
docs/guidance/typescript.md
Normal 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
36
docs/guidance/vitest.md
Normal 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
|
||||
```
|
||||
50
docs/guidance/vue-components.md
Normal file
50
docs/guidance/vue-components.md
Normal 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
62
docs/release-process.md
Normal 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
50
docs/testing/README.md
Normal 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.
|
||||
371
docs/testing/component-testing.md
Normal file
371
docs/testing/component-testing.md
Normal 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.
|
||||
284
docs/testing/store-testing.md
Normal file
284
docs/testing/store-testing.md
Normal 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)
|
||||
})
|
||||
})
|
||||
```
|
||||
332
docs/testing/unit-testing.md
Normal file
332
docs/testing/unit-testing.md
Normal 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) })
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
142
docs/testing/vitest-patterns.md
Normal file
142
docs/testing/vitest-patterns.md
Normal 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'
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user