Compare commits

..

19 Commits

Author SHA1 Message Date
Luke Mino-Altherr
8d90716662 [demo] Add feature flag support to CloudRunButtonWrapper
- Wrap SubscribeToRunButton with FeatureFlaggedRunButton for cloud users
- Enable experimental button variants for cloud subscription flow
- Maintains existing behavior: ComfyQueueButton (with subscription) or SubscribeToRunButton (without)
2025-12-04 18:04:42 -08:00
Luke Mino-Altherr
0b0af89321 [demo] PostHog feature flag integration for Run button experiment
- Add FeatureFlaggedRunButton wrapper component
- Add ExperimentalRunButton with JSON-driven styling
- Extend useFeatureFlags to support remoteConfig and variant payloads
- Support 4 experimental button variants (bold-gradient, animated, playful, minimal)
- Add documentation for feature flag payload structure

NOTE: This is a DEMO and should NOT be merged
2025-12-04 16:12:52 -08:00
Luke Mino-Altherr
d314172b98 [feat] Add remote config support for model upload and asset update feature flags (#7143)
Feature flags for model upload button and asset update options now check
remote config from `/api/features` first, falling back to websocket
feature flags.

- **What**: Added `model_upload_button_enabled` and
`asset_update_options_enabled` to `RemoteConfig` type
- **What**: Updated feature flag getters to prioritize remote config
over websocket flags
- **Why**: Enables dynamic feature control without requiring websocket
connection, consistent with other feature flags pattern

- Pattern consistency with other remote config feature flags
- Proper fallback behavior when remote config is unavailable

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7143-feat-Add-remote-config-support-for-model-upload-and-asset-update-feature-flags-2bf6d73d3650819cb364f0ab69d77dd0)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 11:10:35 -08:00
Comfy Org PR Bot
6c3408592e [backport cloud/1.33] cloud: increase feature flag polling interval to 10min (from 30s) (#7111)
Backport of #7100 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7111-backport-cloud-1-33-cloud-increase-feature-flag-polling-interval-to-10min-from-30s-2be6d73d3650817ea746fa49eb896a2d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-02 19:28:06 -07:00
Comfy Org PR Bot
b6632443dc [backport cloud/1.33] fix: normalize path separators in comfyAPIPlugin for Windows compatibility (#7090)
Backport of #7087 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7090-backport-cloud-1-33-fix-normalize-path-separators-in-comfyAPIPlugin-for-Windows-compat-2bd6d73d365081228497fa23b58b8978)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-02 19:27:41 -07:00
Comfy Org PR Bot
c8a1df3a05 [backport cloud/1.33] feat(api-nodes-pricing): add prices for Kling O1 video model (#7079)
Backport of #7077 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7079-backport-cloud-1-33-feat-api-nodes-pricing-add-prices-for-Kling-O1-video-model-2bc6d73d365081c4ad83d1c5317a9135)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-01 14:18:13 -07:00
Comfy Org PR Bot
39204135ba [backport cloud/1.33] [fix] Prevent drag activation during Vue node resize (#7071)
Backport of #7064 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7071-backport-cloud-1-33-fix-Prevent-drag-activation-during-Vue-node-resize-2bc6d73d36508129b6d4ee43e79de500)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-30 20:44:35 -08:00
Christian Byrne
ffa55cb92b [backport cloud/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7068)
## Summary
- Backport of #7063 to cloud/1.33
- Simplifies Vue node resize to bottom-right corner only

Cherry-picked from d76c59cb14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7068-backport-cloud-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508149bd85c3ae534387b6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-30 20:20:35 -08:00
Comfy Org PR Bot
3856e0deea [backport cloud/1.33] fix: loader node widget value shows placeholder instead of filename on cloud (#7046)
Backport of #7005 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7046-backport-cloud-1-33-fix-loader-node-widget-value-shows-placeholder-instead-of-filename-2bb6d73d365081bd9531c08bbaeb8634)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:34 -07:00
Comfy Org PR Bot
7b589b5502 [backport cloud/1.33] mark vue nodes menu toggle with beta tag (#7052)
Backport of #7047 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7052-backport-cloud-1-33-mark-vue-nodes-menu-toggle-with-beta-tag-2bb6d73d365081da9bb6cb1859c7bf5a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:19 -07:00
Comfy Org PR Bot
72a2581068 [backport cloud/1.33] feat(api-nodes-pricing): add prices for ByteDance seedance-1-0-pro-fast model (#7030)
Backport of #7026 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7030-backport-cloud-1-33-feat-api-nodes-pricing-add-prices-for-ByteDance-seedance-1-0-pro--2b96d73d365081ff9e4feb7b1d147dc5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-29 15:09:55 -07:00
Comfy Org PR Bot
22aea29a0d [backport cloud/1.33] [feat] Show "Finished in" duration for completed jobs in cloud (#7013)
Backport of #6895 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7013-backport-cloud-1-33-feat-Show-Finished-in-duration-for-completed-jobs-in-cloud-2b86d73d365081419f90ee82f5e45253)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 18:31:20 -07:00
Comfy Org PR Bot
637c1995b4 [backport cloud/1.33] [fix] Re-encode cloud-subscription video to VP9 for Safari compatibility (#7012)
Backport of #7006 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7012-backport-cloud-1-33-fix-Re-encode-cloud-subscription-video-to-VP9-for-Safari-compatib-2b86d73d365081a0be8ac57b8afce8a8)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 17:56:57 -07:00
Comfy Org PR Bot
550ca0c911 [backport cloud/1.33] Remove app.graph usage from widgetInput code (#7011)
Backport of #7008 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7011-backport-cloud-1-33-Remove-app-graph-usage-from-widgetInput-code-2b86d73d365081de9460daecfde4bb87)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-27 17:56:50 -07:00
Comfy Org PR Bot
896867b03c [backport cloud/1.33] fix: add filter for combo widgets (#7003)
Backport of #6999 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7003-backport-cloud-1-33-fix-add-filter-for-combo-widgets-2b86d73d3650818daa83c680abf3b5c4)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 14:20:42 -07:00
Christian Byrne
0cd0218946 [backport cloud/1.33] fix: Vue Node <-> Litegraph node height offset normalization (#6978)
## Summary
Backport of #6966 onto cloud/1.33.

- cherry-picked 29dbfa3f
- accepted upstream snapshot updates (only zoomed-in ctrl+shift PNG
conflicted)

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6978-backport-cloud-1-33-fix-Vue-Node-Litegraph-node-height-offset-normalization-2b86d73d365081a19a81f4fac0fd2e91)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 22:38:13 -07:00
Christian Byrne
334404aa3b [backport cloud/1.33] fix: remove LOD from vue nodes (#6983)
## Summary
Backport of #6950 onto cloud/1.33 (clean cherry-pick of 4b87b1fdc).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6983-backport-cloud-1-33-fix-remove-LOD-from-vue-nodes-2b86d73d36508119bafbc5a5a6a5ad42)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 21:40:50 -07:00
Christian Byrne
31d842217b [backport cloud/1.33] fix: don't use registry when only checking for presence of missing nodes (#6972)
## Summary
Backport of #6965 onto cloud/1.33 (clean cherry-pick of 83f04490b).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6972-backport-cloud-1-33-fix-don-t-use-registry-when-only-checking-for-presence-of-missing--2b86d73d36508150af46da84e754df3a)
by [Unito](https://www.unito.io)
2025-11-26 17:50:40 -07:00
Comfy Org PR Bot
3dd3e26003 [backport cloud/1.33] feat: open template via URL in linear mode (#6968)
Backport of #6945 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6968-backport-cloud-1-33-feat-open-template-via-URL-in-linear-mode-2b76d73d365081c4b5d5c0727106fc29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-26 17:32:19 -07:00
21 changed files with 1110 additions and 59 deletions

View File

@@ -0,0 +1,229 @@
# Feature Flags System Explanation
## Overview
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
## Architecture Flow
```
1. Frontend connects via WebSocket
2. Frontend sends client feature flags (first message)
3. Backend responds with server feature flags
4. Frontend stores flags in api.serverFeatureFlags
5. Components use useFeatureFlags() to access flags reactively
```
## Core Implementation
### 1. The `useFeatureFlags` Composable
**Location:** `src/composables/useFeatureFlags.ts`
The composable returns two things:
#### A. Predefined `flags` Object
A reactive object with getter properties for commonly-used feature flags:
```typescript
const { flags } = useFeatureFlags()
// Access predefined flags
flags.supportsPreviewMetadata // boolean | undefined
flags.maxUploadSize // number | undefined
flags.supportsManagerV4 // boolean | undefined
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
```
**Key Points:**
- Uses Vue's `reactive()` to make the object reactive
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
- Returns a `readonly()` wrapper to prevent external mutation
#### B. Generic `featureFlag` Function
A function that creates a computed ref for any feature flag path:
```typescript
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref for any flag
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
```
**Key Points:**
- Accepts any string path (supports dot notation for nested values)
- Returns a `computed()` ref that automatically updates when flags change
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
### 2. The Underlying API Layer
**Location:** `src/scripts/api.ts`
The `ComfyApi` class manages feature flags:
```typescript
class ComfyApi {
// Stores flags received from backend
serverFeatureFlags: Record<string, unknown> = {}
// Retrieves a flag value using dot notation
getServerFeature<T>(featureName: string, defaultValue?: T): T {
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}
}
```
**How Flags Are Received:**
1. WebSocket connection is established
2. Frontend sends client feature flags as first message
3. Backend responds with a `feature_flags` message type
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
**The `get` Function:**
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
- Returns `defaultValue` if the path doesn't exist
### 3. Remote Config Integration
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
```typescript
// Example from modelUploadButtonEnabled
return (
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
)
```
**Why Two Sources?**
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
- WebSocket flags: Real-time capability negotiation, updated on reconnection
## Usage Patterns
### Pattern 1: Using Predefined Flags
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
// In template
if (flags.supportsPreviewMetadata) {
// Use enhanced preview feature
}
// In script
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
```
### Pattern 2: Using Generic featureFlag Function
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
// Use in template (automatically reactive)
// <div v-if="customFeature">New Feature UI</div>
// Use in script
watch(customFeature, (enabled) => {
if (enabled) {
// Feature was enabled
}
})
```
### Pattern 3: Direct API Access (Non-Reactive)
```typescript
import { api } from '@/scripts/api'
// Direct access (not reactive, use sparingly)
if (api.serverSupportsFeature('supports_preview_metadata')) {
// Feature is supported
}
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
```
## Reactivity Explained
The composable is **reactive** because:
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
## Adding New Feature Flags
### Step 1: Add to Enum (if it's a core flag)
```typescript
// In useFeatureFlags.ts
export enum ServerFeatureFlag {
// ... existing flags
MY_NEW_FEATURE = 'my_new_feature'
}
```
### Step 2: Add to flags Object (if commonly used)
```typescript
// In useFeatureFlags.ts flags object
get myNewFeature() {
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
}
```
### Step 3: Use in Components
```typescript
const { flags } = useFeatureFlags()
if (flags.myNewFeature) {
// Use the feature
}
```
**OR** use the generic function without modifying the composable:
```typescript
const { featureFlag } = useFeatureFlags()
const myFeature = featureFlag('my_new_feature', false)
```
## Important Notes
1. **Flags are server-driven**: The backend controls which flags are available
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
## Testing
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
- Mocking `api.getServerFeature()`
- Testing reactive behavior
- Testing default values
- Testing nested paths
## Related Files
- `src/composables/useFeatureFlags.ts` - The main composable
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
- `docs/FEATURE_FLAGS.md` - Full system documentation
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests

196
FEATURE_FLAG_PAYLOAD.md Normal file
View File

@@ -0,0 +1,196 @@
# Feature Flag Payload Shape
## Feature Flag Key
`demo-run-button-experiment`
## Expected Structure
The feature flag value should be either:
### Control (Original Button)
- `false`
- `null`
- `undefined`
- Not set
When any of these values are present, the original SplitButton will be displayed.
### Experiment (Experimental Button)
An object with the following structure:
```typescript
{
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
payload?: { // Optional: styling and content overrides
label?: string, // Button text (default: "Run")
icon?: string, // Icon class (default: variant-specific)
backgroundColor?: string, // Background color/class (default: variant-specific)
textColor?: string, // Text color/class (default: variant-specific)
borderRadius?: string, // Border radius class (default: variant-specific)
padding?: string // Padding class (default: "px-4 py-2")
}
}
```
## Example Payloads
### Bold Gradient Variant
```json
{
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"backgroundColor": "transparent",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
```
### Animated Variant
```json
{
"variant": "animated",
"payload": {
"label": "Launch",
"icon": "icon-[lucide--rocket]",
"backgroundColor": "bg-primary-background",
"textColor": "white",
"borderRadius": "rounded-full"
}
}
```
### Playful Variant
```json
{
"variant": "playful",
"payload": {
"label": "Go!",
"icon": "icon-[lucide--sparkles]",
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
"textColor": "white",
"borderRadius": "rounded-2xl"
}
}
```
### Minimal Variant
```json
{
"variant": "minimal",
"payload": {
"label": "Run",
"icon": "icon-[lucide--play]",
"backgroundColor": "bg-white",
"textColor": "text-gray-800",
"borderRadius": "rounded-md"
}
}
```
## Payload Properties
### `variant` (required)
- Type: `string`
- Description: The variant name that determines the base styling and behavior
- Supported values:
- `"bold-gradient"` - Gradient background with animated effect
- `"animated"` - Pulsing animation effect
- `"playful"` - Sparkle effects with playful styling
- `"minimal"` - Clean, minimal design
- Any custom variant name (will use default styling)
### `payload.label` (optional)
- Type: `string`
- Default: `"Run"` (or variant-specific default)
- Description: The text displayed on the button
### `payload.icon` (optional)
- Type: `string`
- Default: Variant-specific icon
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
- Examples:
- `"icon-[lucide--play]"` - Play icon
- `"icon-[lucide--zap]"` - Lightning bolt
- `"icon-[lucide--rocket]"` - Rocket
- `"icon-[lucide--sparkles]"` - Sparkles
### `payload.backgroundColor` (optional)
- Type: `string`
- Default: Variant-specific background
- Description: Tailwind CSS class or CSS color value for background
- Examples:
- `"bg-primary-background"` - Primary background color
- `"bg-white"` - White background
- `"transparent"` - Transparent (for gradient overlays)
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
### `payload.textColor` (optional)
- Type: `string`
- Default: Variant-specific text color
- Description: Tailwind CSS class or CSS color value for text
- Examples:
- `"white"` - White text (CSS color)
- `"text-white"` - White text (Tailwind class)
- `"text-gray-800"` - Dark gray text
### `payload.borderRadius` (optional)
- Type: `string`
- Default: Variant-specific border radius
- Description: Tailwind CSS border radius class
- Examples:
- `"rounded-md"` - Medium border radius
- `"rounded-xl"` - Extra large border radius
- `"rounded-full"` - Fully rounded (pill shape)
- `"rounded-2xl"` - 2x extra large border radius
### `payload.padding` (optional)
- Type: `string`
- Default: `"px-4 py-2"`
- Description: Tailwind CSS padding classes
- Examples:
- `"px-4 py-2"` - Standard padding
- `"px-6 py-3"` - Larger padding
- `"px-2 py-1"` - Smaller padding
## Backend Integration
The backend should send this feature flag via WebSocket in the `feature_flags` message:
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": {
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
}
}
```
Or to show the control (original button):
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": false
}
}
```
## Notes
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
- The `variant` property is required when the flag is truthy
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
- The component will automatically handle both formats

View File

@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const relativePath = path
.relative(path.join(projectRoot, 'src'), id)
.replace(/\\/g, '/')
const shimFileName = relativePath.replace(/\.ts$/, '.js')
let shimContent = `// Shim for ${relativePath}\n`
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
const fileKey = relativePath.replace(/\.ts$/, '')
const warningMessage = getWarningMessage(fileKey, shimFileName)
if (warningMessage) {

24
cloud-loader-dropdown.md Normal file
View File

@@ -0,0 +1,24 @@
Fixes loader dropdown placeholder
===============================
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
```ts
watch(
modelValue,
(currentValue) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
```
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesnt hit this because it still reads from `widget.options.values` immediately.

View File

@@ -0,0 +1,56 @@
<template>
<!-- If user has active subscription, show ComfyQueueButton (which already has FeatureFlaggedRunButton) -->
<ComfyQueueButton v-if="isActiveSubscription" />
<!-- If subscription required but not active, wrap SubscribeToRunButton with feature flag -->
<FeatureFlaggedRunButton
v-else
flag-key="demo-run-button-experiment"
:on-click="handleSubscribeClick"
>
<template #control>
<!-- SubscribeToRunButton handles its own click (shows subscription dialog) -->
<SubscribeToRunButton />
</template>
</FeatureFlaggedRunButton>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import ComfyQueueButton from './ComfyQueueButton.vue'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
// Get subscription status - replace with actual subscription store/composable
const isActiveSubscription = computed(() => {
// TODO: Replace with actual subscription check
// Example: return useSubscriptionStore().isActiveSubscription
// For now, this would typically come from a store or composable
return false
})
const commandStore = useCommandStore()
// Handle click for experimental button when SubscribeToRunButton is shown
// For experimental variants, trigger queue prompt (same as ComfyQueueButton)
// For control, SubscribeToRunButton handles its own click (shows subscription dialog)
const handleSubscribeClick = async (e: Event) => {
// If this is called from the experimental button, trigger queue prompt
// If called from SubscribeToRunButton (control), it will handle its own logic
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
}
</script>

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.10",
"version": "1.33.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,35 +1,42 @@
<template>
<div class="queue-button-group flex">
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
<FeatureFlaggedRunButton
flag-key="demo-run-button-experiment"
:on-click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
<template #control>
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
text
/>
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
text
/>
</template>
</SplitButton>
</template>
</SplitButton>
</FeatureFlaggedRunButton>
<BatchCountEdit />
</div>
</template>
@@ -52,6 +59,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())

View File

@@ -0,0 +1,252 @@
<template>
<div
:class="buttonClasses"
:style="buttonStyles"
class="experimental-run-button relative overflow-hidden transition-all duration-300"
@click="handleClick"
>
<!-- Animated background for gradient variant -->
<div
v-if="variantName === 'bold-gradient'"
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
/>
<!-- Pulsing animation for animated variant -->
<div
v-if="variantName === 'animated'"
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
/>
<!-- Sparkle effect for playful variant -->
<div
v-if="variantName === 'playful'"
class="absolute inset-0 overflow-hidden"
>
<i
v-for="i in 3"
:key="i"
:class="sparkleClasses[i - 1]"
class="absolute animate-sparkle text-yellow-300"
:style="sparkleStyles[i - 1]"
>
</i>
</div>
<!-- Button content -->
<div class="relative z-10 flex items-center justify-center gap-2">
<i :class="iconClass" class="text-lg" />
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
{{ label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
const props = defineProps<{
variant: FeatureFlagVariant
onClick: () => void
}>()
const variantName = computed(() => props.variant.variant)
const payload = computed(() => props.variant.payload || {})
// Extract styling from payload with defaults
const backgroundColor = computed(
() => (payload.value.backgroundColor as string) || getDefaultColor()
)
const textColor = computed(
() => (payload.value.textColor as string) || getDefaultTextColor()
)
const borderRadius = computed(
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
)
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
const label = computed(() => (payload.value.label as string) || 'Run')
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
// Size matching - should match PrimeVue small button size
const buttonSize = computed(() => {
return 'text-sm' // Match PrimeVue small button
})
function getDefaultColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'transparent' // Gradient overlay handles it
case 'animated':
return 'bg-primary-background'
case 'playful':
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
case 'minimal':
return 'bg-white border-2 border-gray-300'
default:
return 'bg-primary-background'
}
}
function getDefaultTextColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'text-white'
case 'animated':
return 'text-white'
case 'playful':
return 'text-white'
case 'minimal':
return 'text-gray-800'
default:
return 'text-white'
}
}
function getDefaultBorderRadius(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'rounded-xl'
case 'animated':
return 'rounded-full'
case 'playful':
return 'rounded-2xl'
case 'minimal':
return 'rounded-md'
default:
return 'rounded-lg'
}
}
function getDefaultIcon(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'icon-[lucide--zap]'
case 'animated':
return 'icon-[lucide--rocket]'
case 'playful':
return 'icon-[lucide--sparkles]'
case 'minimal':
return 'icon-[lucide--play]'
default:
return 'icon-[lucide--play]'
}
}
const buttonClasses = computed(() => {
const base = [
'cursor-pointer',
'select-none',
'flex',
'items-center',
'justify-center',
padding.value,
borderRadius.value,
'shadow-lg',
'hover:scale-105',
'active:scale-95',
'transition-transform',
buttonSize.value
]
// Add variant-specific classes
if (variantName.value === 'bold-gradient') {
base.push('text-white', 'font-bold')
} else if (variantName.value === 'animated') {
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
} else if (variantName.value === 'playful') {
base.push('text-white', 'font-bold', 'hover:rotate-1')
} else if (variantName.value === 'minimal') {
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
} else {
base.push(backgroundColor.value, textColor.value)
}
return base.join(' ')
})
const buttonStyles = computed(() => {
const styles: Record<string, string> = {}
// Apply custom styles from payload
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
styles.backgroundColor = backgroundColor.value
}
if (payload.value.textColor) {
styles.color = textColor.value
}
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
styles.borderRadius = borderRadius.value
}
return styles
})
const iconClass = computed(() => icon.value)
// Text color handling - can be a CSS class or a color value
const labelClasses = computed(() => {
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
if (textColor.value.startsWith('text-')) {
return textColor.value
}
return ''
})
const labelStyles = computed(() => {
const styles: Record<string, string> = {}
// If textColor is not a Tailwind class, use it as a color value
if (!textColor.value.startsWith('text-')) {
styles.color = textColor.value
}
return styles
})
// Sparkle animation positions for playful variant
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
const sparkleStyles = [
{ animationDelay: '0s', animationDuration: '2s' },
{ animationDelay: '0.5s', animationDuration: '2.5s' },
{ animationDelay: '1s', animationDuration: '3s' }
]
function handleClick() {
props.onClick()
}
</script>
<style scoped>
@keyframes gradient {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 3s ease infinite;
}
@keyframes sparkle {
0%,
100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
.animate-sparkle {
animation: sparkle 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="feature-flagged-run-button">
<!-- Control: Original button -->
<slot v-if="!isExperimentActive" name="control" />
<!-- Experiment: Experimental button -->
<ExperimentalRunButton
v-else-if="variant"
:variant="variant"
:on-click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
import ExperimentalRunButton from './ExperimentalRunButton.vue'
const props = defineProps<{
flagKey?: string
onClick: (e: Event) => void | Promise<void>
}>()
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
const { featureFlag } = useFeatureFlags()
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
flagKey.value,
false
)
const variant = computed<FeatureFlagVariant | null>(() => {
const value = flagValue.value
if (
typeof value === 'object' &&
value !== null &&
'variant' in value &&
typeof (value as FeatureFlagVariant).variant === 'string'
) {
return value as FeatureFlagVariant
}
return null
})
const isExperimentActive = computed(() => {
return variant.value !== null
})
const handleClick = () => {
// Create a synthetic event for the onClick handler
const syntheticEvent = new Event('click') as Event
props.onClick(syntheticEvent)
}
</script>

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -263,7 +264,8 @@ export function useJobList() {
totalPercent: isActive ? totalPercent.value : undefined,
currentNodePercent: isActive ? currentNodePercent.value : undefined,
currentNodeName: isActive ? currentNodeName.value : undefined,
showAddedHint
showAddedHint,
isCloud
})
return {

View File

@@ -1,5 +1,6 @@
import { computed, reactive, readonly } from 'vue'
import { computed, reactive, readonly, ref } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
/**
@@ -9,9 +10,21 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
}
/**
* Feature flag variant structure for experiments
*/
export interface FeatureFlagVariant {
variant: string
payload?: Record<string, unknown>
}
// Demo mode: allows manual override for demonstration
const demoOverrides = ref<Record<string, unknown>>({})
/**
* Composable for reactive access to server-side feature flags
*/
@@ -27,15 +40,57 @@ export function useFeatureFlags() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
return api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.model_upload_button_enabled ??
api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
)
},
get assetUpdateOptionsEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.asset_update_options_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
)
}
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
computed(() => {
// Check demo overrides first
if (demoOverrides.value[featurePath] !== undefined) {
return demoOverrides.value[featurePath] as T
}
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
const remoteConfigKey = featurePath.replace(/-/g, '_')
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
remoteConfigKey
]
if (remoteValue !== undefined) {
return remoteValue as T
}
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
const wsValue = api.getServerFeature(featurePath, undefined)
if (wsValue !== undefined) {
return wsValue as T
}
// Try underscore version for WebSocket flags
const wsValueUnderscore = api.getServerFeature(
featurePath.replace(/-/g, '_'),
undefined
)
if (wsValueUnderscore !== undefined) {
return wsValueUnderscore as T
}
// Return default if nothing found
return defaultValue as T
})
return {
flags: readonly(flags),

View File

@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
name: 'Comfy.Cloud.RemoteConfig',
setup: async () => {
// Poll for config updates every 30 seconds
setInterval(() => void loadRemoteConfig(), 30000)
// Poll for config updates every 10 minutes
setInterval(() => void loadRemoteConfig(), 600_000)
}
})

View File

@@ -2,8 +2,7 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPos,
calculateInputSlotPosFromSlot,
calculateOutputSlotPos,
getSlotPosition
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -3355,16 +3354,6 @@ export class LGraphNode
)
}
/**
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
getSlotPosition(slotIndex: number, isInput: boolean): Point {
return getSlotPosition(this, slotIndex, isInput)
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)

View File

@@ -985,6 +985,7 @@
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
"openWorkflowNewTab": "Open workflow in new tab",

View File

@@ -1,3 +1,5 @@
import type { TelemetryEventName } from '@/platform/telemetry/types'
/**
* Server health alert configuration from the backend
*/
@@ -31,4 +33,7 @@ export type RemoteConfig = {
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
}

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
* - /?template=flux_simple&mode=linear (loads template in linear mode)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
const canvasStore = useCanvasStore()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
/**
* Validates parameter format to prevent path traversal and injection attacks
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
}
/**
* Removes template and source parameters from URL
* Type guard to check if a value is a supported mode
*/
const isSupportedMode = (mode: string): mode is SupportedMode => {
return SUPPORTED_MODES.includes(mode as SupportedMode)
}
/**
* Removes template, source, and mode parameters from URL
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.template
delete newQuery.source
delete newQuery.mode
void router.replace({ query: newQuery })
}
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
return
}
const modeParam = route.query.mode as string | undefined
if (
modeParam &&
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
) {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
}
if (modeParam && !isSupportedMode(modeParam)) {
console.warn(
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
)
}
try {
await templateWorkflows.loadTemplates()
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
canvasStore.linearMode = true
}
} catch (error) {
console.error(

View File

@@ -213,12 +213,13 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
modelValue,
(currentValue) => {
[modelValue, dropdownItems],
([currentValue, _dropdownItems]) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()

View File

@@ -80,7 +80,7 @@ const router = createRouter({
installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source']
keys: ['template', 'source', 'mode']
}
])

View File

@@ -1,5 +1,6 @@
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import { formatDuration } from '@/utils/formatUtil'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
type BuildJobDisplayCtx = {
@@ -11,6 +12,8 @@ type BuildJobDisplayCtx = {
currentNodePercent?: number
currentNodeName?: string
showAddedHint?: boolean
/** Whether the app is running in cloud distribution */
isCloud?: boolean
}
type JobDisplay = {
@@ -122,13 +125,20 @@ export const buildJobDisplay = (
const time = task.executionTimeInSeconds
const preview = task.previewOutput
const iconImageUrl = preview && preview.isImage ? preview.url : undefined
// Cloud shows "Completed in Xh Ym Zs", non-cloud shows filename
const primary = ctx.isCloud
? ctx.t('queue.completedIn', {
duration: formatDuration(task.executionTime ?? 0)
})
: preview?.filename && preview.filename.length
? preview.filename
: buildTitle(task, ctx.t)
return {
iconName: iconForJobState(state),
iconImageUrl,
primary:
preview?.filename && preview.filename.length
? preview.filename
: buildTitle(task, ctx.t),
primary,
secondary: time !== undefined ? `${time.toFixed(2)}s` : '',
showClear: false
}

View File

@@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/
* Tests the behavior of loading templates via URL query parameters:
* - ?template=flux_simple loads the template
* - ?template=flux_simple&source=custom loads from custom source
* - ?template=flux_simple&mode=linear loads template in linear mode
* - Invalid template shows error toast
* - Input validation for template and source parameters
* - Input validation for template, source, and mode parameters
*/
const preservedQueryMocks = vi.hoisted(() => ({
@@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({
})
}))
// Mock canvas store
const mockCanvasStore = {
linearMode: false
}
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
})
it('does not load template when no query param present', () => {
@@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => {
mockQueryParams = {
template: 'flux_simple',
source: 'custom',
mode: 'linear',
other: 'param'
}
@@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => {
query: { other: 'param' }
})
})
it('sets linear mode when mode=linear and template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('does not set linear mode when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockCanvasStore.linearMode).toBe(false)
})
it('does not set linear mode when mode parameter is not linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
})
it('rejects invalid mode parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('handles array mode params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = {
template: 'flux_simple',
mode: ['linear', 'graph'] as any
}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load when mode param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('warns about unsupported mode values but continues loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
consoleSpy.mockRestore()
})
it('accepts supported mode parameter: linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('accepts valid format but warns about unsupported modes', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']
for (const mode of unsupportedModes) {
vi.clearAllMocks()
consoleSpy.mockClear()
mockCanvasStore.linearMode = false
mockQueryParams = { template: 'flux_simple', mode }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
}
consoleSpy.mockRestore()
})
})