load template workflow via URL query param (#6546)

## Summary

Adds support for loading templates via URL query parameters. Users can
now share direct links to templates.

To test:

1. checkout this branch
2. start dev server on port 5173
3. go to http://localhost:5173/?template=image_qwen_image_edit_2509

**Examples:**
- `/?template=default` - Loads template with default source
- `/?template=flux_simple&source=custom` - Loads from custom source

Includes error handling with toast notifications for invalid templates
and comprehensive test coverage.

---------

Co-authored-by: Christian Byrne <c.byrne@comfy.org>
This commit is contained in:
Christian Byrne
2025-11-02 21:23:56 -08:00
committed by GitHub
parent 4810b5728a
commit 9cd7a39f09
5 changed files with 326 additions and 6 deletions

6
pnpm-lock.yaml generated
View File

@@ -12,15 +12,9 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0

View File

@@ -780,6 +780,9 @@
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
},
"error": {
"templateNotFound": "Template \"{templateName}\" not found"
}
},
"graphCanvasMenu": {

View File

@@ -0,0 +1,96 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useTemplateWorkflows } from './useTemplateWorkflows'
/**
* Composable for loading templates from URL query parameters
*
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
const route = useRoute()
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
/**
* Validates parameter format to prevent path traversal and injection attacks
*/
const isValidParameter = (param: string): boolean => {
return /^[a-zA-Z0-9_-]+$/.test(param)
}
/**
* Loads template from URL query parameters if present
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async () => {
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
return
}
// Validate template name format
if (!isValidParameter(templateParam)) {
console.warn(
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
)
return
}
const sourceParam = (route.query.source as string | undefined) || 'default'
// Validate source parameter format
if (!isValidParameter(sourceParam)) {
console.warn(
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
)
return
}
// Load template with error handling
try {
await templateWorkflows.loadTemplates()
const success = await templateWorkflows.loadWorkflowTemplate(
templateParam,
sourceParam
)
if (!success) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('templateWorkflows.error.templateNotFound', {
templateName: templateParam
}),
life: 3000
})
}
} catch (error) {
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
error
)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.errorLoadingTemplate'),
life: 3000
})
}
}
return {
loadTemplateFromUrl
}
}

View File

@@ -53,6 +53,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -80,6 +81,9 @@ setupAutoQueueHandler()
useProgressFavicon()
useBrowserTabTitle()
// Template URL loading
const { loadTemplateFromUrl } = useTemplateUrlLoader()
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
@@ -349,6 +353,9 @@ const onGraphReady = () => {
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Load template from URL if present
void loadTemplateFromUrl()
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()

View File

@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
/**
* Unit tests for useTemplateUrlLoader composable
*
* 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
* - Invalid template shows error toast
* - Input validation for template and source parameters
*/
// Mock vue-router
let mockQueryParams: Record<string, string | undefined> = {}
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
query: mockQueryParams
}))
}))
// Mock template workflows composable
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
() => ({
useTemplateWorkflows: () => ({
loadTemplates: mockLoadTemplates,
loadWorkflowTemplate: mockLoadWorkflowTemplate
})
})
)
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
// Mock i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key: string, params?: any) => {
if (key === 'g.error') return 'Error'
if (key === 'templateWorkflows.error.templateNotFound') {
return `Template "${params?.templateName}" not found`
}
if (key === 'g.errorLoadingTemplate') return 'Failed to load template'
return key
})
})
}))
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
})
it('does not load template when no query param present', () => {
mockQueryParams = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
expect(mockLoadTemplates).not.toHaveBeenCalled()
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadTemplates).toHaveBeenCalledTimes(1)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
})
it('uses default source when source param is not provided', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
})
it('uses custom source when source param is provided', async () => {
mockQueryParams = { template: 'custom-template', source: 'custom-module' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'custom-template',
'custom-module'
)
})
it('shows error toast when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Template "invalid-template" not found',
life: 3000
})
})
it('handles array query params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = { template: ['first', 'second'] as any }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load when param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('rejects invalid template parameter with special characters', () => {
// Test path traversal attempt
mockQueryParams = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load invalid template
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('rejects invalid template parameter with slash', () => {
mockQueryParams = { template: 'path/to/template' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load invalid template
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('accepts valid template parameter formats', async () => {
const validTemplates = [
'flux_simple',
'flux-kontext-dev',
'template123',
'My_Template-2'
]
for (const template of validTemplates) {
vi.clearAllMocks()
mockQueryParams = { template }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(template, 'default')
}
})
it('rejects invalid source parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', source: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load with invalid source
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('accepts valid source parameter formats', async () => {
const validSources = ['default', 'custom-module', 'my_source', 'source123']
for (const source of validSources) {
vi.clearAllMocks()
mockQueryParams = { template: 'flux_simple', source }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
source
)
}
})
it('shows error toast when exception is thrown', async () => {
mockQueryParams = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load template',
life: 3000
})
})
})