{
+ 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
+ })
+ })
+})
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
index 61e5577d8..f77a3a75e 100644
--- a/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/components/ImagePreview.test.ts
@@ -208,10 +208,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
- // After clicking, component shows loading state (Skeleton), not img
- expect(wrapper.find('skeleton-stub').exists()).toBe(true)
- expect(wrapper.find('img').exists()).toBe(false)
-
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
diff --git a/tests-ui/tests/store/executionStore.test.ts b/tests-ui/tests/store/executionStore.test.ts
index 8632c4922..5b7c934ab 100644
--- a/tests-ui/tests/store/executionStore.test.ts
+++ b/tests-ui/tests/store/executionStore.test.ts
@@ -156,14 +156,11 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
expect(result).toBe('123')
})
- it('should return null when conversion fails', () => {
+ it('should return undefined when conversion fails', () => {
// Mock app.graph.getNodeById to return null (node not found)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
- // This should throw an error as the node is not found
- expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
- 'Subgraph not found: 999'
- )
+ expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
})
})
diff --git a/tests-ui/tests/store/subgraphStore.test.ts b/tests-ui/tests/store/subgraphStore.test.ts
index b97e1b149..d41c3e817 100644
--- a/tests-ui/tests/store/subgraphStore.test.ts
+++ b/tests-ui/tests/store/subgraphStore.test.ts
@@ -13,6 +13,11 @@ import {
createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
+// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
+vi.mock('@/platform/telemetry', () => ({
+ useTelemetry: () => null
+}))
+
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts
index f6cb74804..2af5a6ee9 100644
--- a/tests-ui/tests/utils/graphTraversalUtil.test.ts
+++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts
@@ -35,14 +35,18 @@ function createMockNode(
isSubgraph?: boolean
subgraph?: Subgraph
callback?: () => void
+ graph?: LGraph
} = {}
): LGraphNode {
- return {
+ const node = {
id,
isSubgraphNode: options.isSubgraph ? () => true : undefined,
subgraph: options.subgraph,
- onExecutionStart: options.callback
+ onExecutionStart: options.callback,
+ graph: options.graph
} as unknown as LGraphNode
+ options.graph?.nodes?.push(node)
+ return node
}
// Mock graph factory
@@ -50,20 +54,28 @@ function createMockGraph(nodes: LGraphNode[]): LGraph {
return {
_nodes: nodes,
nodes: nodes,
+ isRootGraph: true,
getNodeById: (id: string | number) =>
nodes.find((n) => String(n.id) === String(id)) || null
} as unknown as LGraph
}
// Mock subgraph factory
-function createMockSubgraph(id: string, nodes: LGraphNode[]): Subgraph {
- return {
+function createMockSubgraph(
+ id: string,
+ nodes: LGraphNode[],
+ rootGraph?: LGraph
+): Subgraph {
+ const graph = {
id,
_nodes: nodes,
nodes: nodes,
+ isRootGraph: false,
+ rootGraph,
getNodeById: (nodeId: string | number) =>
nodes.find((n) => String(n.id) === String(nodeId)) || null
} as unknown as Subgraph
+ return graph
}
describe('graphTraversalUtil', () => {
@@ -983,31 +995,30 @@ describe('graphTraversalUtil', () => {
describe('getExecutionIdsForSelectedNodes', () => {
it('should return simple IDs for top-level nodes', () => {
- const nodes = [
- createMockNode('123'),
- createMockNode('456'),
- createMockNode('789')
- ]
+ const graph = createMockGraph([])
+ createMockNode('123', { graph })
+ createMockNode('456', { graph })
+ createMockNode('789', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
})
it('should expand subgraph nodes to include all children', () => {
+ const graph = createMockGraph([])
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
- const nodes = [
- createMockNode('1'),
- createMockNode('2', { isSubgraph: true, subgraph })
- ]
+ createMockNode('1', { graph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
})
it('should handle deeply nested subgraphs correctly', () => {
+ const graph = createMockGraph([])
const deepNodes = [createMockNode('30'), createMockNode('31')]
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
@@ -1019,7 +1030,8 @@ describe('graphTraversalUtil', () => {
const topNode = createMockNode('10', {
isSubgraph: true,
- subgraph: midSubgraph
+ subgraph: midSubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
@@ -1028,16 +1040,15 @@ describe('graphTraversalUtil', () => {
})
it('should handle mixed selection of regular and subgraph nodes', () => {
+ const graph = createMockGraph([])
const subNodes = [createMockNode('100'), createMockNode('101')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
- const nodes = [
- createMockNode('1'),
- createMockNode('2', { isSubgraph: true, subgraph }),
- createMockNode('3')
- ]
+ createMockNode('1', { graph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
+ createMockNode('3', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual([
'3',
@@ -1054,10 +1065,12 @@ describe('graphTraversalUtil', () => {
})
it('should handle subgraph with no children', () => {
+ const graph = createMockGraph([])
const emptySubgraph = createMockSubgraph('empty-uuid', [])
const node = createMockNode('1', {
isSubgraph: true,
- subgraph: emptySubgraph
+ subgraph: emptySubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([node])
@@ -1079,9 +1092,11 @@ describe('graphTraversalUtil', () => {
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
}
+ const graph = createMockGraph([])
const topNode = createMockNode('1', {
isSubgraph: true,
- subgraph: currentSubgraph
+ subgraph: currentSubgraph,
+ graph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
@@ -1103,12 +1118,11 @@ describe('graphTraversalUtil', () => {
createMockNode('101') // Same ID as in subgraph1
])
- const nodes = [
- createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
- createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
- ]
+ const graph = createMockGraph([])
+ createMockNode('1', { isSubgraph: true, subgraph: subgraph1, graph })
+ createMockNode('2', { isSubgraph: true, subgraph: subgraph2, graph })
- const executionIds = getExecutionIdsForSelectedNodes(nodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toEqual([
'2',
@@ -1128,9 +1142,11 @@ describe('graphTraversalUtil', () => {
}
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
+ const graph = createMockGraph([])
const node = createMockNode('parent', {
isSubgraph: true,
- subgraph: bigSubgraph
+ subgraph: bigSubgraph,
+ graph
})
const start = performance.now()
@@ -1157,19 +1173,17 @@ describe('graphTraversalUtil', () => {
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
- const topNode = createMockNode('100', {
- isSubgraph: true,
- subgraph: midSubgraph
- })
-
+ const graph = createMockGraph([])
// Select nodes at different nesting levels
- const selectedNodes = [
- createMockNode('1'), // Root level
- topNode, // Contains subgraph
- createMockNode('2') // Root level
- ]
+ createMockNode('100', {
+ isSubgraph: true,
+ subgraph: midSubgraph,
+ graph
+ })
+ createMockNode('1', { graph })
+ createMockNode('2', { graph })
- const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
+ const executionIds = getExecutionIdsForSelectedNodes(graph.nodes)
expect(executionIds).toContain('1')
expect(executionIds).toContain('2')
@@ -1178,6 +1192,17 @@ describe('graphTraversalUtil', () => {
expect(executionIds).toContain('100:202')
expect(executionIds).toContain('100:202:300')
})
+ it('should resolve full execution path of a node inside a subgraph', () => {
+ const graph = createMockGraph([])
+ const subgraph = createMockSubgraph('sub-uuid', [], graph)
+ createMockNode('11', { graph: subgraph })
+ createMockNode('10', { graph: subgraph })
+ createMockNode('2', { isSubgraph: true, subgraph, graph })
+
+ const executionIds = getExecutionIdsForSelectedNodes(subgraph.nodes)
+
+ expect(executionIds).toEqual(['2:10', '2:11'])
+ })
})
})
})
diff --git a/vite.config.mts b/vite.config.mts
index cffc21408..fdeadc086 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -1,3 +1,4 @@
+import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
@@ -26,6 +27,15 @@ const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
+// Open Graph / Twitter Meta Tags Constants
+const VITE_OG_URL = 'https://cloud.comfy.org'
+const VITE_OG_TITLE =
+ 'Comfy Cloud: Run ComfyUI online | Zero Setup, Powerful GPUs, Create anywhere'
+const VITE_OG_DESC =
+ 'Bring your creative ideas to life with Comfy Cloud. Build and run your workflows to generate stunning images and videos instantly using powerful GPUs — all from your browser, no installation required.'
+const VITE_OG_IMAGE = `${VITE_OG_URL}/assets/images/og-image.png`
+const VITE_OG_KEYWORDS = 'ComfyUI, Comfy Cloud, ComfyUI online'
+
// Auto-detect cloud mode from DEV_SERVER_COMFYUI_URL
const DEV_SERVER_COMFYUI_ENV_URL = process.env.DEV_SERVER_COMFYUI_URL
const IS_CLOUD_URL = DEV_SERVER_COMFYUI_ENV_URL?.includes('.comfy.org')
@@ -121,7 +131,7 @@ const gcsRedirectProxyConfig: ProxyOptions = {
}
export default defineConfig({
- base: '',
+ base: DISTRIBUTION === 'cloud' ? '/' : '',
server: {
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
watch: {
@@ -221,41 +231,130 @@ export default defineConfig({
: [vue()]),
tailwindcss(),
comfyAPIPlugin(IS_DEV),
- generateImportMapPlugin([
- {
- name: 'vue',
- pattern: 'vue',
- entry: './dist/vue.esm-browser.prod.js'
- },
- {
- name: 'vue-i18n',
- pattern: 'vue-i18n',
- entry: './dist/vue-i18n.esm-browser.prod.js'
- },
- {
- name: 'primevue',
- pattern: /^primevue\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true
- },
- {
- name: '@primevue/themes',
- pattern: /^@primevue\/themes\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true
- },
- {
- name: '@primevue/forms',
- pattern: /^@primevue\/forms\/?.*/,
- entry: './index.mjs',
- recursiveDependence: true,
- override: {
- '@primeuix/forms': {
- entry: ''
- }
+ // Twitter/Open Graph meta tags plugin (cloud distribution only)
+ {
+ name: 'inject-twitter-meta',
+ transformIndexHtml(html) {
+ if (DISTRIBUTION !== 'cloud') return html
+
+ return {
+ html,
+ tags: [
+ // Basic SEO
+ { tag: 'title', children: VITE_OG_TITLE, injectTo: 'head' },
+ {
+ tag: 'meta',
+ attrs: { name: 'description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'keywords', content: VITE_OG_KEYWORDS },
+ injectTo: 'head'
+ },
+
+ // Twitter Card tags
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:card', content: 'summary_large_image' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:title', content: VITE_OG_TITLE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:image', content: VITE_OG_IMAGE },
+ injectTo: 'head'
+ },
+
+ // Open Graph tags (Twitter fallback & other platforms)
+ {
+ tag: 'meta',
+ attrs: { property: 'og:title', content: VITE_OG_TITLE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:description', content: VITE_OG_DESC },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:image', content: VITE_OG_IMAGE },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:url', content: VITE_OG_URL },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:type', content: 'website' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:site_name', content: 'Comfy Cloud' },
+ injectTo: 'head'
+ },
+ {
+ tag: 'meta',
+ attrs: { property: 'og:locale', content: 'en_US' },
+ injectTo: 'head'
+ }
+ ]
}
}
- ]),
+ },
+ // Skip import-map generation for cloud builds to keep bundle small
+ ...(DISTRIBUTION !== 'cloud'
+ ? [
+ generateImportMapPlugin([
+ {
+ name: 'vue',
+ pattern: 'vue',
+ entry: './dist/vue.esm-browser.prod.js'
+ },
+ {
+ name: 'vue-i18n',
+ pattern: 'vue-i18n',
+ entry: './dist/vue-i18n.esm-browser.prod.js'
+ },
+ {
+ name: 'primevue',
+ pattern: /^primevue\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true
+ },
+ {
+ name: '@primevue/themes',
+ pattern: /^@primevue\/themes\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true
+ },
+ {
+ name: '@primevue/forms',
+ pattern: /^@primevue\/forms\/?.*/,
+ entry: './index.mjs',
+ recursiveDependence: true,
+ override: {
+ '@primeuix/forms': {
+ entry: ''
+ }
+ }
+ }
+ ])
+ ]
+ : []),
Icons({
compiler: 'vue3',
@@ -289,6 +388,27 @@ export default defineConfig({
template: 'treemap' // or 'sunburst', 'network'
})
]
+ : []),
+
+ // Sentry sourcemap upload plugin
+ // Only runs during cloud production builds when all Sentry env vars are present
+ // Requires: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT env vars
+ ...(DISTRIBUTION === 'cloud' &&
+ process.env.SENTRY_AUTH_TOKEN &&
+ process.env.SENTRY_ORG &&
+ process.env.SENTRY_PROJECT &&
+ !IS_DEV
+ ? [
+ sentryVitePlugin({
+ org: process.env.SENTRY_ORG,
+ project: process.env.SENTRY_PROJECT,
+ authToken: process.env.SENTRY_AUTH_TOKEN,
+ sourcemaps: {
+ // Delete source maps after upload to prevent public access
+ filesToDeleteAfterUpload: ['**/*.map']
+ }
+ })
+ ]
: [])
],