mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
6 Commits
bl/fix-qpo
...
glary/temp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b352f4aa7f | ||
|
|
dadc9f6004 | ||
|
|
3aab8fa1f0 | ||
|
|
1e03e38a57 | ||
|
|
cd0a8eb258 | ||
|
|
ee3e4989a9 |
114
browser_tests/tests/templateUrlLoading.spec.ts
Normal file
114
browser_tests/tests/templateUrlLoading.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user