Compare commits

...

6 Commits

6 changed files with 383 additions and 13 deletions

View File

@@ -0,0 +1,114 @@
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MOCK_TEMPLATE_INDEX: WorkflowTemplates[] = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'test_template',
title: 'Test Template',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A test template for URL loading.'
}
]
}
]
const MOCK_WORKFLOW_JSON = {
last_node_id: 2,
last_link_id: 1,
nodes: [
{
id: 1,
type: 'KSampler',
pos: [100, 100],
size: [300, 200],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [1] }],
properties: {}
},
{
id: 2,
type: 'EmptyLatentImage',
pos: [500, 100],
size: [300, 200],
inputs: [{ name: 'LATENT', type: 'LATENT', link: 1 }],
properties: {}
}
],
links: [[1, 1, 0, 2, 0, 'LATENT']],
groups: [],
config: {},
extra: {},
version: 0.4
}
test.describe('Template URL loading spinner', { tag: ['@workflow'] }, () => {
test('shows spinner while loading template from URL param', async ({
comfyPage
}) => {
const blockUIMask = comfyPage.page.locator('.p-blockui-mask')
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(MOCK_TEMPLATE_INDEX),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
let resolveTemplate: (() => void) | undefined
const templateDelay = new Promise<void>((resolve) => {
resolveTemplate = resolve
})
await comfyPage.page.route(
'**/templates/test_template.json',
async (route) => {
await templateDelay
await route.fulfill({
status: 200,
body: JSON.stringify(MOCK_WORKFLOW_JSON),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
)
await comfyPage.page.goto(`${comfyPage.url}?template=test_template`)
await expect(blockUIMask).toBeVisible({ timeout: 10_000 })
resolveTemplate?.()
await expect(blockUIMask).toBeHidden({ timeout: 30_000 })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
})
test('dismisses spinner normally when no template param', async ({
comfyPage
}) => {
const blockUIMask = comfyPage.page.locator('.p-blockui-mask')
await comfyPage.page.goto(comfyPage.url)
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect(blockUIMask).toBeHidden({ timeout: 30_000 })
})
})

View File

@@ -547,7 +547,10 @@ onMounted(async () => {
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
const deferSpinnerForTemplate = workflowPersistence.hasTemplateIntent()
if (!deferSpinnerForTemplate) {
workspaceStore.spinner = false
}
}
comfyApp.canvas.onSelectionChange = useChainCallback(
@@ -567,11 +570,12 @@ onMounted(async () => {
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
workspaceStore.spinner = false
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {

View File

@@ -67,13 +67,20 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
})
}))
const mockLoadTemplateFromUrl = vi.fn()
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
})
})
async () => {
const { ref, shallowRef, readonly } = await import('vue')
return {
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: mockLoadTemplateFromUrl,
isLoading: readonly(ref(false)),
error: readonly(shallowRef(null)),
isReady: readonly(ref(false))
})
}
}
)
vi.mock('@/stores/commandStore', () => ({
@@ -82,9 +89,10 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
let mockRouteQuery: Record<string, string | string[] | undefined> = {}
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
query: mockRouteQuery
}),
useRouter: () => ({
replace: vi.fn()
@@ -161,6 +169,7 @@ describe('useWorkflowPersistenceV2', () => {
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
mockRouteQuery = {}
settingMocks.persistRef!.value = true
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
@@ -357,4 +366,71 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('loadTemplateFromUrlIfPresent', () => {
it('returns true and calls loadTemplateFromUrl when template param exists', async () => {
mockRouteQuery = { template: 'flux_simple' }
const { loadTemplateFromUrlIfPresent } = useWorkflowPersistenceV2()
const result = await loadTemplateFromUrlIfPresent()
expect(result).toBe(true)
expect(mockLoadTemplateFromUrl).toHaveBeenCalledTimes(1)
})
it('returns false and skips load when no template param', async () => {
mockRouteQuery = {}
const { loadTemplateFromUrlIfPresent } = useWorkflowPersistenceV2()
const result = await loadTemplateFromUrlIfPresent()
expect(result).toBe(false)
expect(mockLoadTemplateFromUrl).not.toHaveBeenCalled()
})
it('returns false when template param is an array', async () => {
mockRouteQuery = { template: ['first', 'second'] }
const { loadTemplateFromUrlIfPresent } = useWorkflowPersistenceV2()
const result = await loadTemplateFromUrlIfPresent()
expect(result).toBe(false)
expect(mockLoadTemplateFromUrl).not.toHaveBeenCalled()
})
})
describe('hasTemplateIntent', () => {
it('returns true when route query has template param', () => {
mockRouteQuery = { template: 'flux_simple' }
const { hasTemplateIntent } = useWorkflowPersistenceV2()
expect(hasTemplateIntent()).toBe(true)
})
it('returns false when route query has no template param', () => {
mockRouteQuery = {}
const { hasTemplateIntent } = useWorkflowPersistenceV2()
expect(hasTemplateIntent()).toBe(false)
})
it('returns true when preserved query has template in sessionStorage', () => {
mockRouteQuery = {}
sessionStorage.setItem(
'Comfy.PreservedQuery.template',
JSON.stringify({ template: 'flux_simple' })
)
const { hasTemplateIntent } = useWorkflowPersistenceV2()
expect(hasTemplateIntent()).toBe(true)
})
it('returns false when sessionStorage has invalid preserved query', () => {
mockRouteQuery = {}
sessionStorage.setItem('Comfy.PreservedQuery.template', 'invalid-json{')
const { hasTemplateIntent } = useWorkflowPersistenceV2()
expect(hasTemplateIntent()).toBe(false)
})
})
})

View File

@@ -187,13 +187,30 @@ export function useWorkflowPersistenceV2() {
}
}
const loadTemplateFromUrlIfPresent = async () => {
const hasTemplateIntent = (): boolean => {
if (route.query.template && typeof route.query.template === 'string') {
return true
}
try {
const raw = sessionStorage.getItem('Comfy.PreservedQuery.template')
if (!raw) return false
const parsed = JSON.parse(raw)
return typeof parsed?.template === 'string'
} catch {
return false
}
}
const loadTemplateFromUrlIfPresent = async (): Promise<boolean> => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
return true
}
return false
}
const loadSharedWorkflowFromUrlIfPresent = async () => {
@@ -307,6 +324,7 @@ export function useWorkflowPersistenceV2() {
}
return {
hasTemplateIntent,
initializeWorkflow,
loadSharedWorkflowFromUrlIfPresent,
loadTemplateFromUrlIfPresent,

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReadonly } from 'vue'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
@@ -398,4 +399,145 @@ describe('useTemplateUrlLoader', () => {
consoleSpy.mockRestore()
})
describe('loading state refs', () => {
it('returns readonly refs for isLoading, error, and isReady', () => {
const { isLoading, error, isReady } = useTemplateUrlLoader()
expect(isReadonly(isLoading)).toBe(true)
expect(isReadonly(error)).toBe(true)
expect(isReadonly(isReady)).toBe(true)
})
it('transitions refs through success lifecycle', async () => {
mockQueryParams = { template: 'flux_simple' }
let resolveLoadTemplates: (() => void) | undefined
mockLoadTemplates.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveLoadTemplates = resolve
})
)
const { loadTemplateFromUrl, isLoading, error, isReady } =
useTemplateUrlLoader()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
const loadPromise = loadTemplateFromUrl()
expect(isLoading.value).toBe(true)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
resolveLoadTemplates?.()
await loadPromise
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(true)
})
it('sets isReady after failed template load without setting error', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl, isLoading, error, isReady } =
useTemplateUrlLoader()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(true)
})
it('sets error and isReady when load throws', async () => {
mockQueryParams = { template: 'flux_simple' }
const thrownError = new Error('Network error')
mockLoadTemplates.mockRejectedValueOnce(thrownError)
const { loadTemplateFromUrl, isLoading, error, isReady } =
useTemplateUrlLoader()
const loadPromise = loadTemplateFromUrl()
expect(isLoading.value).toBe(true)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
await loadPromise
expect(isLoading.value).toBe(false)
expect(error.value).toBe(thrownError)
expect(isReady.value).toBe(true)
})
it('keeps refs unchanged when template param is missing', async () => {
mockQueryParams = {}
const { loadTemplateFromUrl, isLoading, error, isReady } =
useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
expect(mockLoadTemplates).not.toHaveBeenCalled()
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('keeps refs unchanged for invalid params before async work', async () => {
const invalidQueries: Record<string, string>[] = [
{ template: '../../../etc/passwd' },
{ template: 'flux_simple', source: '../malicious' },
{ template: 'flux_simple', mode: '../malicious' }
]
for (const query of invalidQueries) {
vi.clearAllMocks()
mockQueryParams = query
const { loadTemplateFromUrl, isLoading, error, isReady } =
useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(isLoading.value).toBe(false)
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
expect(mockLoadTemplates).not.toHaveBeenCalled()
}
})
it('resets stale state on retry after error', async () => {
mockQueryParams = { template: 'flux_simple' }
const thrownError = new Error('Network error')
mockLoadTemplates.mockRejectedValueOnce(thrownError)
const { loadTemplateFromUrl, error, isReady } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(error.value).toBe(thrownError)
expect(isReady.value).toBe(true)
mockLoadTemplates.mockResolvedValueOnce(true)
const retryPromise = loadTemplateFromUrl()
expect(error.value).toBe(null)
expect(isReady.value).toBe(false)
await retryPromise
expect(error.value).toBe(null)
expect(isReady.value).toBe(true)
})
})
})

View File

@@ -1,4 +1,5 @@
import { useToast } from 'primevue/usetoast'
import { readonly, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -33,6 +34,10 @@ export function useTemplateUrlLoader() {
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
const isLoading = ref(false)
const error = shallowRef<Error | null>(null)
const isReady = ref(false)
/**
* Validates parameter format to prevent path traversal and injection attacks
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
@@ -65,6 +70,9 @@ export function useTemplateUrlLoader() {
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async () => {
error.value = null
isReady.value = false
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
@@ -105,6 +113,8 @@ export function useTemplateUrlLoader() {
)
}
isLoading.value = true
try {
await templateWorkflows.loadTemplates()
@@ -122,14 +132,15 @@ export function useTemplateUrlLoader() {
})
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
} catch (error) {
} catch (e) {
const caught = e instanceof Error ? e : new Error(String(e))
error.value = caught
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
error
caught
)
toast.add({
severity: 'error',
@@ -137,12 +148,17 @@ export function useTemplateUrlLoader() {
detail: t('g.errorLoadingTemplate')
})
} finally {
isLoading.value = false
isReady.value = true
cleanupUrlParams()
clearPreservedQuery(TEMPLATE_NAMESPACE)
}
}
return {
loadTemplateFromUrl
loadTemplateFromUrl,
isLoading: readonly(isLoading),
error: readonly(error),
isReady: readonly(isReady)
}
}