mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
9 Commits
v1.44.16
...
glary/scop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67e3dbce9 | ||
|
|
7e75921fb5 | ||
|
|
48b953be31 | ||
|
|
72877c8c1a | ||
|
|
5fbcea6b27 | ||
|
|
ac36dc47a4 | ||
|
|
aef71852f0 | ||
|
|
94b570a177 | ||
|
|
846412af17 |
@@ -114,7 +114,7 @@ await expect(async () => {
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
44
apps/website/e2e/demos.spec.ts
Normal file
44
apps/website/e2e/demos.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non-existent demo returns 404', async ({ page }) => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
BIN
apps/website/public/images/demos/image-to-video-og.png
Normal file
BIN
apps/website/public/images/demos/image-to-video-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/image-to-video-thumb.webp
Normal file
BIN
apps/website/public/images/demos/image-to-video-thumb.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-og.png
Normal file
BIN
apps/website/public/images/demos/workflow-templates-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-thumb.webp
Normal file
BIN
apps/website/public/images/demos/workflow-templates-thumb.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
@@ -31,4 +31,28 @@ Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
67
apps/website/src/components/demos/ArcadeEmbed.vue
Normal file
67
apps/website/src/components/demos/ArcadeEmbed.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const loaded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-16"
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
|
||||
>
|
||||
<div
|
||||
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
|
||||
/>
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ t('demos.loading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
class="size-full"
|
||||
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
|
||||
:title="`${t('demos.embed.label', locale)}: ${title}`"
|
||||
loading="lazy"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{{ t('demos.noscript', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow ml-2 underline"
|
||||
:href="`https://demo.arcade.software/${arcadeId}`"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('demos.noscript.link', locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</noscript>
|
||||
</section>
|
||||
</template>
|
||||
60
apps/website/src/components/demos/DemoHeroSection.vue
Normal file
60
apps/website/src/components/demos/DemoHeroSection.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
difficulty,
|
||||
estimatedTime,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedTime: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
|
||||
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{{ t(difficultyKey, locale) }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ t(estimatedTime as TranslationKey, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
59
apps/website/src/components/demos/DemoNavSection.vue
Normal file
59
apps/website/src/components/demos/DemoNavSection.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
nextTitle,
|
||||
nextSlug,
|
||||
nextThumbnail,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
nextTitle: string
|
||||
nextSlug: string
|
||||
nextThumbnail: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
|
||||
>
|
||||
<a :href="nextHref" class="shrink-0 lg:w-1/2">
|
||||
<img
|
||||
:src="nextThumbnail"
|
||||
:alt="nextTitle"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
50
apps/website/src/components/demos/DemoTranscript.vue
Normal file
50
apps/website/src/components/demos/DemoTranscript.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { transcript, locale = 'en' } = defineProps<{
|
||||
transcript: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-12"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-comfy-canvas text-left"
|
||||
:aria-expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<span class="text-sm font-semibold tracking-wide uppercase">
|
||||
{{ t('demos.transcript.label', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-2 text-xs">
|
||||
{{ t('demos.transcript.note', locale) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
:class="
|
||||
cn(
|
||||
expanded ? 'mt-4' : 'sr-only',
|
||||
'text-primary-warm-gray text-sm/relaxed'
|
||||
)
|
||||
"
|
||||
v-html="transcript"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/config/demos.ts
Normal file
68
apps/website/src/config/demos.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface Demo {
|
||||
readonly slug: string
|
||||
readonly arcadeId: string
|
||||
readonly category: TranslationKey
|
||||
readonly title: TranslationKey
|
||||
readonly description: TranslationKey
|
||||
readonly ogImage: string
|
||||
readonly thumbnail: string
|
||||
readonly estimatedTime: TranslationKey
|
||||
readonly durationIso: string
|
||||
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
readonly tags: readonly string[]
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
{
|
||||
slug: 'image-to-video',
|
||||
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
|
||||
category: 'demos.category.templates',
|
||||
title: 'demos.image-to-video.title',
|
||||
description: 'demos.image-to-video.description',
|
||||
transcript: 'demos.image-to-video.transcript',
|
||||
ogImage: '/images/demos/image-to-video-og.png',
|
||||
thumbnail: '/images/demos/image-to-video-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
arcadeId: 'KhqcXDElnFWklo7ACBqE',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.workflow-templates.title',
|
||||
description: 'demos.workflow-templates.description',
|
||||
transcript: 'demos.workflow-templates.transcript',
|
||||
ogImage: '/images/demos/workflow-templates-og.png',
|
||||
thumbnail: '/images/demos/workflow-templates-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
}
|
||||
]
|
||||
|
||||
export function getDemoBySlug(slug: string): Demo | undefined {
|
||||
return demos.find((demo) => demo.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextDemo(slug: string): Demo {
|
||||
if (demos.length === 0) {
|
||||
throw new Error('No demos configured')
|
||||
}
|
||||
const index = demos.findIndex((demo) => demo.slug === slug)
|
||||
if (index === -1) {
|
||||
throw new Error(`Unknown demo slug: ${slug}`)
|
||||
}
|
||||
return demos[(index + 1) % demos.length]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const baseRoutes = {
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact'
|
||||
|
||||
@@ -3542,6 +3542,80 @@ const translations = {
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
},
|
||||
|
||||
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
|
||||
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
|
||||
|
||||
'demos.image-to-video.title': {
|
||||
en: 'Create a Video from an Image',
|
||||
'zh-CN': '从图片创建视频'
|
||||
},
|
||||
'demos.image-to-video.description': {
|
||||
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
|
||||
'zh-CN':
|
||||
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
|
||||
},
|
||||
'demos.image-to-video.transcript': {
|
||||
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.workflow-templates.title': {
|
||||
en: 'Browse Workflow Templates',
|
||||
'zh-CN': '浏览工作流模板'
|
||||
},
|
||||
'demos.workflow-templates.description': {
|
||||
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
|
||||
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
|
||||
},
|
||||
'demos.workflow-templates.transcript': {
|
||||
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
|
||||
'demos.transcript.note': {
|
||||
en: '(for accessibility & search)',
|
||||
'zh-CN': '(无障碍和搜索)'
|
||||
},
|
||||
'demos.loading': {
|
||||
en: 'Loading interactive demo…',
|
||||
'zh-CN': '正在加载互动演示…'
|
||||
},
|
||||
'demos.noscript': {
|
||||
en: 'This interactive demo requires JavaScript.',
|
||||
'zh-CN': '此互动演示需要 JavaScript。'
|
||||
},
|
||||
'demos.noscript.link': {
|
||||
en: 'View on Arcade →',
|
||||
'zh-CN': '在 Arcade 上查看 →'
|
||||
},
|
||||
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
|
||||
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
|
||||
'demos.difficulty.intermediate': {
|
||||
en: 'Intermediate',
|
||||
'zh-CN': '中级'
|
||||
},
|
||||
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
|
||||
'demos.embed.label': {
|
||||
en: 'Interactive demo',
|
||||
'zh-CN': '互动演示'
|
||||
},
|
||||
'demos.comingSoon.title': {
|
||||
en: 'Coming Soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
'demos.comingSoon.body': {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
en: "What's next?",
|
||||
'zh-CN': '接下来看什么?'
|
||||
|
||||
@@ -109,6 +109,7 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
|
||||
139
apps/website/src/pages/demos/[slug].astro
Normal file
139
apps/website/src/pages/demos/[slug].astro
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category)}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript)}
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title)}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
/>
|
||||
</BaseLayout>
|
||||
8
apps/website/src/pages/demos/index.astro
Normal file
8
apps/website/src/pages/demos/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
143
apps/website/src/pages/zh-CN/demos/[slug].astro
Normal file
143
apps/website/src/pages/zh-CN/demos/[slug].astro
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category, 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript, 'zh-CN')}
|
||||
locale="zh-CN"
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title, 'zh-CN')}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
</BaseLayout>
|
||||
17
apps/website/src/pages/zh-CN/demos/index.astro
Normal file
17
apps/website/src/pages/zh-CN/demos/index.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
|
||||
<section class="flex min-h-[60vh] items-center justify-center px-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light">
|
||||
{t('demos.comingSoon.title', 'zh-CN')}
|
||||
</h1>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{t('demos.comingSoon.body', 'zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
49
browser_tests/tests/workflowDeleteSettings.spec.ts
Normal file
49
browser_tests/tests/workflowDeleteSettings.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW_NAME = 'test-confirm-delete'
|
||||
|
||||
async function startDeletingFromSidebar(comfyPage: ComfyPage) {
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(WORKFLOW_NAME).click({ button: 'right' })
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
}
|
||||
|
||||
test.describe('Comfy.Workflow.ConfirmDelete', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflowAs(WORKFLOW_NAME)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('on (default): right-click → Delete prompts the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', true)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await expect(comfyPage.confirmDialog.delete).toBeVisible()
|
||||
})
|
||||
|
||||
test('off: right-click → Delete bypasses the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await expect(comfyPage.confirmDialog.root).toBeHidden()
|
||||
await expect
|
||||
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
|
||||
.not.toContain(WORKFLOW_NAME)
|
||||
})
|
||||
})
|
||||
@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Lodash Functions
|
||||
## Mocking Utility Functions
|
||||
|
||||
Mocking utility functions like debounce:
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ export type {
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
BulkRevokeApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysData,
|
||||
BulkRevokeWorkspaceMemberApiKeysError,
|
||||
BulkRevokeWorkspaceMemberApiKeysErrors,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponses,
|
||||
CancelJobData,
|
||||
CancelJobError,
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -307,6 +318,28 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
GetLegacyHistoryErrors,
|
||||
GetLegacyJobByIdData,
|
||||
GetLegacyJobByIdErrors,
|
||||
GetLegacyJobOutputsData,
|
||||
GetLegacyJobOutputsErrors,
|
||||
GetLegacyModelsByFolderData,
|
||||
GetLegacyModelsByFolderErrors,
|
||||
GetLegacyModelsData,
|
||||
GetLegacyModelsErrors,
|
||||
GetLegacyObjectInfoByNodeClassData,
|
||||
GetLegacyObjectInfoByNodeClassErrors,
|
||||
GetLegacyPromptByIdData,
|
||||
GetLegacyPromptByIdErrors,
|
||||
GetLegacyUserdataV2Data,
|
||||
GetLegacyUserdataV2Errors,
|
||||
GetLegacyViewMetadataData,
|
||||
GetLegacyViewMetadataErrors,
|
||||
GetLogsData,
|
||||
GetLogsError,
|
||||
GetLogsErrors,
|
||||
@@ -505,6 +538,7 @@ export type {
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
@@ -719,6 +753,13 @@ export type {
|
||||
SubscribeResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
SyncApiKeyError,
|
||||
SyncApiKeyErrors,
|
||||
SyncApiKeyRequest,
|
||||
SyncApiKeyResponse,
|
||||
SyncApiKeyResponse2,
|
||||
SyncApiKeyResponses,
|
||||
SystemStatsResponse,
|
||||
TagInfo,
|
||||
TagsModificationResponse,
|
||||
|
||||
785
packages/ingest-types/src/types.gen.ts
generated
785
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
497
packages/ingest-types/src/zod.gen.ts
generated
497
packages/ingest-types/src/zod.gen.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import {
|
||||
type ComfyNode,
|
||||
type ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ExecutingWsMessage } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -1446,7 +1447,11 @@ export class GroupNodeHandler {
|
||||
).runningInternalNodeId = innerNodeIndex
|
||||
api.dispatchCustomEvent(
|
||||
type as 'executing',
|
||||
getEvent(detail, `${this.node.id}`, this.node) as string
|
||||
getEvent(
|
||||
detail,
|
||||
`${this.node.id}`,
|
||||
this.node
|
||||
) as unknown as ExecutingWsMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1459,8 +1464,11 @@ export class GroupNodeHandler {
|
||||
|
||||
const executing = handleEvent(
|
||||
'executing',
|
||||
(d) => (typeof d === 'string' ? d : undefined),
|
||||
(_d, id) => id
|
||||
(d) => (typeof d === 'string' ? d : (d?.display_node ?? d?.node)),
|
||||
(d, id) =>
|
||||
typeof d === 'object'
|
||||
? { ...d, node: id, display_node: id }
|
||||
: { prompt_id: '', node: id, display_node: id }
|
||||
)
|
||||
|
||||
const executed = handleEvent(
|
||||
|
||||
@@ -1,239 +1,456 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
const mockMixpanel = vi.hoisted(() => ({
|
||||
init: vi.fn(),
|
||||
track: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
people: { set: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock('mixpanel-browser', () => ({
|
||||
default: mockMixpanel
|
||||
}))
|
||||
|
||||
const mockOnUserResolved = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: vi.fn()
|
||||
useCurrentUser: () => ({ onUserResolved: mockOnUserResolved })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
mode: { value: 'workflow' },
|
||||
isAppMode: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/topupTracker', () => ({
|
||||
checkForCompletedTopup: vi.fn(),
|
||||
const topupMocks = vi.hoisted(() => ({
|
||||
startTopupTracking: vi.fn(),
|
||||
clearTopupTracking: vi.fn(),
|
||||
startTopupTracking: vi.fn()
|
||||
checkForCompletedTopup: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
vi.mock('@/platform/telemetry/topupTracker', () => topupMocks)
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: hoisted.mockNodeDefsByName
|
||||
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
|
||||
getExecutionContext: () => ({
|
||||
is_template: false,
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return hoisted.mockActiveWorkflow
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
get knownTemplateNames() {
|
||||
return hoisted.mockKnownTemplateNames
|
||||
},
|
||||
getTemplateByName: (_name: string) => hoisted.mockTemplateByName,
|
||||
getEnglishMetadata: () => null
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
function mockNode(
|
||||
type: string,
|
||||
isSubgraph = false
|
||||
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
|
||||
return {
|
||||
type,
|
||||
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
|
||||
let result = initial
|
||||
for (const node of hoisted.mockNodes) {
|
||||
result = reducer(result, node)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({
|
||||
normalizeSurveyResponses: mockNormalizeSurveyResponses
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
}))
|
||||
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import { MixpanelTelemetryProvider } from '@/platform/telemetry/providers/cloud/MixpanelTelemetryProvider'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ShareFlowMetadata,
|
||||
SurveyResponses,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '@/platform/telemetry/types'
|
||||
import { TelemetryEvents } from '@/platform/telemetry/types'
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
const waitForMixpanelInit = () =>
|
||||
vi.waitFor(() => expect(mockMixpanel.init).toHaveBeenCalled())
|
||||
|
||||
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
|
||||
|
||||
describe('MixpanelTelemetryProvider — without configured token', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.mockNodes.length = 0
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
hoisted.mockActiveWorkflow = null
|
||||
hoisted.mockKnownTemplateNames = new Set()
|
||||
hoisted.mockTemplateByName = null
|
||||
delete (window as unknown as ConfigWindow).__CONFIG__
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
it('warns and disables itself when no mixpanel_token is configured', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
provider.trackUserLoggedIn()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Mixpanel token')
|
||||
)
|
||||
expect(mockMixpanel.track).not.toHaveBeenCalled()
|
||||
expect(mockMixpanel.init).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
hoisted.mockNodeDefsByName['LoadImage'] = {
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(false)
|
||||
expect(context.toolkit_node_names).toEqual([])
|
||||
expect(context.toolkit_node_count).toBe(0)
|
||||
})
|
||||
|
||||
it('detects individual toolkit nodes by type name', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('detects blueprint toolkit nodes via python_module', () => {
|
||||
const blueprintType = 'SubgraphBlueprint.text_to_image'
|
||||
hoisted.mockNodes.push(mockNode(blueprintType, true))
|
||||
hoisted.mockNodeDefsByName[blueprintType] = {
|
||||
name: blueprintType,
|
||||
python_module: 'comfy_essentials'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual([blueprintType])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(2)
|
||||
})
|
||||
|
||||
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
|
||||
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
|
||||
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
|
||||
name: 'RecraftRemoveBackgroundNode',
|
||||
python_module: 'comfy_extras.nodes_api',
|
||||
api_node: true
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_api_nodes).toBe(true)
|
||||
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
})
|
||||
|
||||
it('uses node.type as tracking name when nodeDef is missing', () => {
|
||||
hoisted.mockNodes.push(mockNode('ImageCrop'))
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
|
||||
describe('template detection', () => {
|
||||
it('detects a regular template by name', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'flux-dev',
|
||||
fullFilename: 'flux-dev.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('flux-dev')
|
||||
})
|
||||
|
||||
it('detects an app mode template whose name ends with .app', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set([
|
||||
'templates-qwen_multiangle.app'
|
||||
])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
// getFilenameDetails strips ".app.json" as a compound extension, yielding
|
||||
// filename = "templates-qwen_multiangle" — the previous code would fail here.
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'templates-qwen_multiangle',
|
||||
fullFilename: 'templates-qwen_multiangle.app.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('templates-qwen_multiangle.app')
|
||||
})
|
||||
|
||||
it('does not flag a non-template workflow as a template', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'my-custom-workflow',
|
||||
fullFilename: 'my-custom-workflow.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — with configured token', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(window as unknown as ConfigWindow).__CONFIG__ = {
|
||||
mixpanel_token: 'test-token'
|
||||
}
|
||||
mockMixpanel.init.mockImplementation((_token, config) => {
|
||||
config?.loaded?.()
|
||||
})
|
||||
mockNormalizeSurveyResponses.mockImplementation((responses) => responses)
|
||||
})
|
||||
|
||||
it('initializes Mixpanel and tracks events synchronously after the loaded callback fires', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
|
||||
provider.trackUserLoggedIn()
|
||||
|
||||
expect(mockMixpanel.init).toHaveBeenCalledWith(
|
||||
'test-token',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_LOGGED_IN,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('queues events fired before loaded() and flushes them once Mixpanel reports ready', async () => {
|
||||
const captured: { trigger: (() => void) | null } = { trigger: null }
|
||||
mockMixpanel.init.mockImplementationOnce((_token, config) => {
|
||||
captured.trigger = config?.loaded ?? null
|
||||
})
|
||||
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
|
||||
provider.trackSignupOpened()
|
||||
provider.trackUserLoggedIn()
|
||||
expect(mockMixpanel.track).not.toHaveBeenCalled()
|
||||
|
||||
captured.trigger?.()
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_SIGN_UP_OPENED,
|
||||
{}
|
||||
)
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_LOGGED_IN,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips events that are in the default disabled set', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
const metadata: WorkflowImportMetadata = {
|
||||
missing_node_count: 0,
|
||||
missing_node_types: []
|
||||
}
|
||||
provider.trackWorkflowOpened(metadata)
|
||||
|
||||
expect(mockMixpanel.track).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['opened' as const, TelemetryEvents.USER_EMAIL_VERIFY_OPENED],
|
||||
['requested' as const, TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED],
|
||||
['completed' as const, TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED]
|
||||
])(
|
||||
'trackEmailVerification(%s) dispatches %s',
|
||||
async (stage, expectedEvent) => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackEmailVerification(stage)
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, {})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[
|
||||
'modal_opened' as const,
|
||||
TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
|
||||
],
|
||||
['subscribe_clicked' as const, TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED]
|
||||
])('trackSubscription(%s) dispatches %s', async (event, expectedEvent) => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackSubscription(event)
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, {})
|
||||
})
|
||||
|
||||
it('writes normalized survey properties to Mixpanel.people on submit', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
|
||||
const normalized = {
|
||||
industry: 'tech',
|
||||
industry_normalized: 'Software / IT / AI',
|
||||
industry_raw: 'tech',
|
||||
useCase: 'fun',
|
||||
useCase_normalized: 'Personal & Hobby',
|
||||
useCase_raw: 'fun'
|
||||
}
|
||||
mockNormalizeSurveyResponses.mockReturnValueOnce(normalized)
|
||||
|
||||
const responses: SurveyResponses = { industry: 'tech', useCase: 'fun' }
|
||||
provider.trackSurvey('submitted', responses)
|
||||
|
||||
expect(mockNormalizeSurveyResponses).toHaveBeenCalledWith(responses)
|
||||
expect(mockMixpanel.people.set).toHaveBeenCalledWith(normalized)
|
||||
})
|
||||
|
||||
it('does not write to Mixpanel.people for survey "opened"', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.people.set.mockClear()
|
||||
|
||||
provider.trackSurvey('opened')
|
||||
|
||||
expect(mockMixpanel.people.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards user identification when onUserResolved callback fires with a user id', async () => {
|
||||
new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
|
||||
expect(mockOnUserResolved).toHaveBeenCalled()
|
||||
const callback = mockOnUserResolved.mock.calls[0]?.[0] as (user: {
|
||||
id?: string
|
||||
}) => void
|
||||
callback({ id: 'user-42' })
|
||||
|
||||
expect(mockMixpanel.identify).toHaveBeenCalledWith('user-42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(window as unknown as ConfigWindow).__CONFIG__ = {
|
||||
mixpanel_token: 'test-token'
|
||||
}
|
||||
mockMixpanel.init.mockImplementation((_token, config) => {
|
||||
config?.loaded?.()
|
||||
})
|
||||
mockNormalizeSurveyResponses.mockImplementation((responses) => responses)
|
||||
})
|
||||
|
||||
type Trackable = (provider: MixpanelTelemetryProvider) => void
|
||||
|
||||
const templateMetadata: TemplateMetadata = { workflow_name: 't' }
|
||||
const templateLibraryMetadata: TemplateLibraryMetadata = { source: 'menu' }
|
||||
const templateLibraryClosedMetadata: TemplateLibraryClosedMetadata = {
|
||||
template_selected: false,
|
||||
time_spent_seconds: 0
|
||||
}
|
||||
const workflowImportMetadata: WorkflowImportMetadata = {
|
||||
missing_node_count: 0,
|
||||
missing_node_types: []
|
||||
}
|
||||
const workflowSavedMetadata: WorkflowSavedMetadata = {
|
||||
is_app: false,
|
||||
is_new: false
|
||||
}
|
||||
const defaultViewSetMetadata: DefaultViewSetMetadata = {
|
||||
default_view: 'graph'
|
||||
}
|
||||
const enterLinearMetadata: EnterLinearMetadata = {}
|
||||
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
|
||||
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
|
||||
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
|
||||
const authMetadata: AuthMetadata = {}
|
||||
|
||||
it.each<
|
||||
[string, Trackable, (typeof TelemetryEvents)[keyof typeof TelemetryEvents]]
|
||||
>([
|
||||
[
|
||||
'trackAddApiCreditButtonClicked',
|
||||
(p) => p.trackAddApiCreditButtonClicked(),
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED
|
||||
],
|
||||
[
|
||||
'trackMonthlySubscriptionSucceeded',
|
||||
(p) => p.trackMonthlySubscriptionSucceeded(),
|
||||
TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED
|
||||
],
|
||||
[
|
||||
'trackMonthlySubscriptionCancelled',
|
||||
(p) => p.trackMonthlySubscriptionCancelled(),
|
||||
TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED
|
||||
],
|
||||
[
|
||||
'trackApiCreditTopupSucceeded',
|
||||
(p) => p.trackApiCreditTopupSucceeded(),
|
||||
TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED
|
||||
],
|
||||
[
|
||||
'trackTemplate',
|
||||
(p) => p.trackTemplate(templateMetadata),
|
||||
TelemetryEvents.TEMPLATE_WORKFLOW_OPENED
|
||||
],
|
||||
[
|
||||
'trackTemplateLibraryOpened',
|
||||
(p) => p.trackTemplateLibraryOpened(templateLibraryMetadata),
|
||||
TelemetryEvents.TEMPLATE_LIBRARY_OPENED
|
||||
],
|
||||
[
|
||||
'trackTemplateLibraryClosed',
|
||||
(p) => p.trackTemplateLibraryClosed(templateLibraryClosedMetadata),
|
||||
TelemetryEvents.TEMPLATE_LIBRARY_CLOSED
|
||||
],
|
||||
[
|
||||
'trackWorkflowImported',
|
||||
(p) => p.trackWorkflowImported(workflowImportMetadata),
|
||||
TelemetryEvents.WORKFLOW_IMPORTED
|
||||
],
|
||||
[
|
||||
'trackWorkflowSaved',
|
||||
(p) => p.trackWorkflowSaved(workflowSavedMetadata),
|
||||
TelemetryEvents.WORKFLOW_SAVED
|
||||
],
|
||||
[
|
||||
'trackDefaultViewSet',
|
||||
(p) => p.trackDefaultViewSet(defaultViewSetMetadata),
|
||||
TelemetryEvents.DEFAULT_VIEW_SET
|
||||
],
|
||||
[
|
||||
'trackEnterLinear',
|
||||
(p) => p.trackEnterLinear(enterLinearMetadata),
|
||||
TelemetryEvents.ENTER_LINEAR_MODE
|
||||
],
|
||||
[
|
||||
'trackShareFlow',
|
||||
(p) => p.trackShareFlow(shareFlowMetadata),
|
||||
TelemetryEvents.SHARE_FLOW
|
||||
],
|
||||
[
|
||||
'trackExecutionError',
|
||||
(p) => p.trackExecutionError(executionErrorMetadata),
|
||||
TelemetryEvents.EXECUTION_ERROR
|
||||
],
|
||||
[
|
||||
'trackExecutionSuccess',
|
||||
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
|
||||
TelemetryEvents.EXECUTION_SUCCESS
|
||||
],
|
||||
[
|
||||
'trackAuth',
|
||||
(p) => p.trackAuth(authMetadata),
|
||||
TelemetryEvents.USER_AUTH_COMPLETED
|
||||
],
|
||||
[
|
||||
'trackSignupOpened',
|
||||
(p) => p.trackSignupOpened(),
|
||||
TelemetryEvents.USER_SIGN_UP_OPENED
|
||||
]
|
||||
])('%s dispatches %s', async (_name, invoke, expectedEvent) => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
invoke(provider)
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
expectedEvent,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('trackApiCreditTopupButtonPurchaseClicked includes the credit_amount payload', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackApiCreditTopupButtonPurchaseClicked(42)
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
|
||||
{ credit_amount: 42 }
|
||||
)
|
||||
})
|
||||
|
||||
it('trackRunButton populates RunButtonProperties from the execution context', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackRunButton({
|
||||
subscribe_to_run: true,
|
||||
trigger_source: 'button'
|
||||
})
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.RUN_BUTTON_CLICKED,
|
||||
expect.objectContaining({
|
||||
subscribe_to_run: true,
|
||||
workflow_type: 'custom',
|
||||
trigger_source: 'button',
|
||||
view_mode: 'workflow',
|
||||
is_app_mode: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackRunButton({ trigger_source: 'keybinding' })
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'keybinding' })
|
||||
)
|
||||
|
||||
mockMixpanel.track.mockClear()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({ trigger_source: 'unknown' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — topup delegation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete (window as unknown as ConfigWindow).__CONFIG__
|
||||
})
|
||||
|
||||
it('forwards topup lifecycle calls to the topupTracker utility', () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
|
||||
provider.startTopupTracking()
|
||||
provider.clearTopupTracking()
|
||||
const result = provider.checkForCompletedTopup([])
|
||||
|
||||
expect(topupMocks.startTopupTracking).toHaveBeenCalled()
|
||||
expect(topupMocks.clearTopupTracking).toHaveBeenCalled()
|
||||
expect(topupMocks.checkForCompletedTopup).toHaveBeenCalledWith([])
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
215
src/platform/telemetry/utils/getExecutionContext.test.ts
Normal file
215
src/platform/telemetry/utils/getExecutionContext.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: hoisted.mockNodeDefsByName
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return hoisted.mockActiveWorkflow
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
get knownTemplateNames() {
|
||||
return hoisted.mockKnownTemplateNames
|
||||
},
|
||||
getTemplateByName: (_name: string) => hoisted.mockTemplateByName,
|
||||
getEnglishMetadata: () => null
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
function mockNode(
|
||||
type: string,
|
||||
isSubgraph = false
|
||||
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
|
||||
return {
|
||||
type,
|
||||
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
|
||||
let result = initial
|
||||
for (const node of hoisted.mockNodes) {
|
||||
result = reducer(result, node)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
import { getExecutionContext } from './getExecutionContext'
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.mockNodes.length = 0
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
hoisted.mockActiveWorkflow = null
|
||||
hoisted.mockKnownTemplateNames = new Set()
|
||||
hoisted.mockTemplateByName = null
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['LoadImage'] = {
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(false)
|
||||
expect(context.toolkit_node_names).toEqual([])
|
||||
expect(context.toolkit_node_count).toBe(0)
|
||||
})
|
||||
|
||||
it('detects individual toolkit nodes by type name', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('detects blueprint toolkit nodes via python_module', () => {
|
||||
const blueprintType = 'SubgraphBlueprint.text_to_image'
|
||||
hoisted.mockNodes.push(mockNode(blueprintType, true))
|
||||
hoisted.mockNodeDefsByName[blueprintType] = {
|
||||
name: blueprintType,
|
||||
python_module: 'comfy_essentials'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual([blueprintType])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(2)
|
||||
})
|
||||
|
||||
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
|
||||
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
|
||||
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
|
||||
name: 'RecraftRemoveBackgroundNode',
|
||||
python_module: 'comfy_extras.nodes_api',
|
||||
api_node: true
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_api_nodes).toBe(true)
|
||||
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
})
|
||||
|
||||
it('uses node.type as tracking name when nodeDef is missing', () => {
|
||||
hoisted.mockNodes.push(mockNode('ImageCrop'))
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
|
||||
describe('template detection', () => {
|
||||
it('detects a regular template by name', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'flux-dev',
|
||||
fullFilename: 'flux-dev.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('flux-dev')
|
||||
})
|
||||
|
||||
it('detects an app mode template whose name ends with .app', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set([
|
||||
'templates-qwen_multiangle.app'
|
||||
])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
// getFilenameDetails strips ".app.json" as a compound extension, yielding
|
||||
// filename = "templates-qwen_multiangle" — the previous code would fail here.
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'templates-qwen_multiangle',
|
||||
fullFilename: 'templates-qwen_multiangle.app.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.workflow_name).toBe('templates-qwen_multiangle.app')
|
||||
})
|
||||
|
||||
it('does not flag a non-template workflow as a template', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'my-custom-workflow',
|
||||
fullFilename: 'my-custom-workflow.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
const zNodeType = z.string()
|
||||
const zJobId = z.string()
|
||||
export type JobId = z.infer<typeof zJobId>
|
||||
const zWorkflowId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
@@ -56,6 +57,7 @@ const zProgressWsMessage = z.object({
|
||||
value: z.number().int(),
|
||||
max: z.number().int(),
|
||||
prompt_id: zJobId,
|
||||
workflow_id: zWorkflowId.optional(),
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
@@ -65,6 +67,7 @@ const zNodeProgressState = z.object({
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zJobId,
|
||||
workflow_id: zWorkflowId.optional(),
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
@@ -72,13 +75,15 @@ const zNodeProgressState = z.object({
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zJobId,
|
||||
workflow_id: zWorkflowId.optional(),
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
prompt_id: zJobId
|
||||
prompt_id: zJobId,
|
||||
workflow_id: zWorkflowId.optional()
|
||||
})
|
||||
|
||||
const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
@@ -88,6 +93,7 @@ const zExecutedWsMessage = zExecutingWsMessage.extend({
|
||||
|
||||
const zExecutionWsMessageBase = z.object({
|
||||
prompt_id: zJobId,
|
||||
workflow_id: zWorkflowId.optional(),
|
||||
timestamp: z.number().int()
|
||||
})
|
||||
|
||||
@@ -115,7 +121,8 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
|
||||
const zProgressTextWsMessage = z.object({
|
||||
nodeId: zNodeId,
|
||||
text: z.string(),
|
||||
prompt_id: z.string().optional()
|
||||
prompt_id: z.string().optional(),
|
||||
workflow_id: zWorkflowId.optional()
|
||||
})
|
||||
|
||||
const zNotificationWsMessage = z.object({
|
||||
|
||||
@@ -23,8 +23,7 @@ import type {
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
AssetDownloadWsMessage,
|
||||
@@ -213,11 +212,7 @@ type AsCustomEvents<T> = {
|
||||
|
||||
/** Handles differing event and API signatures. */
|
||||
type ApiToEventType<T = ApiCalls> = {
|
||||
[K in keyof T]: K extends 'status'
|
||||
? StatusWsMessageStatus
|
||||
: K extends 'executing'
|
||||
? NodeId
|
||||
: T[K]
|
||||
[K in keyof T]: K extends 'status' ? StatusWsMessageStatus : T[K]
|
||||
}
|
||||
|
||||
/** Dictionary of types used in the detail for a custom event */
|
||||
@@ -728,10 +723,7 @@ export class ComfyApi extends EventTarget {
|
||||
this.dispatchCustomEvent('status', msg.data.status ?? null)
|
||||
break
|
||||
case 'executing':
|
||||
this.dispatchCustomEvent(
|
||||
'executing',
|
||||
msg.data.display_node || msg.data.node
|
||||
)
|
||||
this.dispatchCustomEvent('executing', msg.data)
|
||||
break
|
||||
case 'execution_start':
|
||||
case 'execution_error':
|
||||
|
||||
@@ -11,12 +11,22 @@ const {
|
||||
mockNodeExecutionIdToNodeLocatorId,
|
||||
mockNodeIdToNodeLocatorId,
|
||||
mockNodeLocatorIdToNodeExecutionId,
|
||||
mockShowTextPreview
|
||||
mockShowTextPreview,
|
||||
mockActiveWorkflow,
|
||||
mockRevokePreviewsByExecutionId
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn()
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockActiveWorkflow: {
|
||||
current: null as null | {
|
||||
activeState?: { id?: string }
|
||||
initialState?: { id?: string }
|
||||
path?: string
|
||||
}
|
||||
},
|
||||
mockRevokePreviewsByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -35,7 +45,10 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
|
||||
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.current
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -70,9 +83,9 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
revokePreviewsByExecutionId: mockRevokePreviewsByExecutionId
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -440,6 +453,469 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - active workflow gating', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function makeProgressNodes(
|
||||
nodeId: string,
|
||||
jobId: string
|
||||
): Record<string, NodeProgressState> {
|
||||
return {
|
||||
[nodeId]: {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: nodeId,
|
||||
prompt_id: jobId,
|
||||
display_node_id: nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireProgressState(
|
||||
jobId: string,
|
||||
nodes: Record<string, NodeProgressState>,
|
||||
workflowId?: string
|
||||
) {
|
||||
const handler = apiEventHandlers.get('progress_state')
|
||||
if (!handler) throw new Error('progress_state handler not bound')
|
||||
handler(
|
||||
new CustomEvent('progress_state', {
|
||||
detail: { nodes, prompt_id: jobId, workflow_id: workflowId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function fireProgress(
|
||||
jobId: string,
|
||||
nodeId: string,
|
||||
workflowId?: string,
|
||||
value = 5,
|
||||
max = 10
|
||||
) {
|
||||
const handler = apiEventHandlers.get('progress')
|
||||
if (!handler) throw new Error('progress handler not bound')
|
||||
handler(
|
||||
new CustomEvent('progress', {
|
||||
detail: {
|
||||
value,
|
||||
max,
|
||||
prompt_id: jobId,
|
||||
node: nodeId,
|
||||
workflow_id: workflowId
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
mockActiveWorkflow.current = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('always updates per-job progress regardless of active workflow', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgressState(
|
||||
'job-other',
|
||||
makeProgressNodes('1', 'job-other'),
|
||||
'wf-other'
|
||||
)
|
||||
|
||||
expect(store.nodeProgressStatesByJob).toHaveProperty('job-other')
|
||||
})
|
||||
|
||||
it('skips global mirror when message workflow_id mismatches active workflow', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgressState(
|
||||
'job-other',
|
||||
makeProgressNodes('1', 'job-other'),
|
||||
'wf-other'
|
||||
)
|
||||
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('updates global mirror when message workflow_id matches active workflow', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgressState('job-1', makeProgressNodes('1', 'job-1'), 'wf-active')
|
||||
|
||||
expect(store.nodeProgressStates).toEqual(makeProgressNodes('1', 'job-1'))
|
||||
})
|
||||
|
||||
it('falls back to jobIdToWorkflowId mapping when workflow_id missing', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
store.registerJobWorkflowIdMapping('job-other', 'wf-other')
|
||||
|
||||
fireProgressState('job-other', makeProgressNodes('1', 'job-other'))
|
||||
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('falls back to session path mapping when no id mapping is registered', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
store.ensureSessionWorkflowPath('job-other', '/wf-other.json')
|
||||
|
||||
fireProgressState('job-other', makeProgressNodes('1', 'job-other'))
|
||||
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves single-tab behaviour when ownership is unresolvable', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgressState('job-unknown', makeProgressNodes('1', 'job-unknown'))
|
||||
|
||||
expect(store.nodeProgressStates).toEqual(
|
||||
makeProgressNodes('1', 'job-unknown')
|
||||
)
|
||||
})
|
||||
|
||||
it('updates mirror when there is no active workflow', () => {
|
||||
mockActiveWorkflow.current = null
|
||||
|
||||
fireProgressState('job-1', makeProgressNodes('1', 'job-1'), 'wf-1')
|
||||
|
||||
expect(store.nodeProgressStates).toEqual(makeProgressNodes('1', 'job-1'))
|
||||
})
|
||||
|
||||
it('skips preview revocation for non-active workflow messages', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
mockRevokePreviewsByExecutionId.mockClear()
|
||||
|
||||
fireProgressState(
|
||||
'job-other',
|
||||
makeProgressNodes('1', 'job-other'),
|
||||
'wf-other'
|
||||
)
|
||||
|
||||
expect(mockRevokePreviewsByExecutionId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes previews for active workflow messages', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
mockRevokePreviewsByExecutionId.mockClear()
|
||||
|
||||
fireProgressState('job-1', makeProgressNodes('1', 'job-1'), 'wf-active')
|
||||
|
||||
expect(mockRevokePreviewsByExecutionId).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('skips _executingNodeProgress on workflow_id mismatch', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgress('job-other', '1', 'wf-other')
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('updates _executingNodeProgress on workflow_id match', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
|
||||
fireProgress('job-1', '1', 'wf-active', 7, 10)
|
||||
|
||||
expect(store._executingNodeProgress).toEqual({
|
||||
value: 7,
|
||||
max: 10,
|
||||
prompt_id: 'job-1',
|
||||
node: '1',
|
||||
workflow_id: 'wf-active'
|
||||
})
|
||||
})
|
||||
|
||||
it('execution_start from a non-active workflow does not steal activeJobId', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const handler = apiEventHandlers.get('execution_start')
|
||||
if (!handler) throw new Error('execution_start handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-other'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
})
|
||||
|
||||
it('execution_start from active workflow adopts activeJobId', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const handler = apiEventHandlers.get('execution_start')
|
||||
if (!handler) throw new Error('execution_start handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-active'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('execution_success from a non-active workflow does not clear activeJobId', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const startHandler = apiEventHandlers.get('execution_start')
|
||||
if (!startHandler) throw new Error('execution_start handler not bound')
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-active'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const successHandler = apiEventHandlers.get('execution_success')
|
||||
if (!successHandler) throw new Error('execution_success handler not bound')
|
||||
successHandler(
|
||||
new CustomEvent('execution_success', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-other'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('execution_interrupted from a non-active workflow does not clear activeJobId', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const startHandler = apiEventHandlers.get('execution_start')
|
||||
if (!startHandler) throw new Error('execution_start handler not bound')
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-active'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const intHandler = apiEventHandlers.get('execution_interrupted')
|
||||
if (!intHandler) throw new Error('execution_interrupted handler not bound')
|
||||
intHandler(
|
||||
new CustomEvent('execution_interrupted', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
timestamp: 0,
|
||||
node_id: '1',
|
||||
node_type: 'X',
|
||||
executed: [],
|
||||
workflow_id: 'wf-other'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('executing from a non-active workflow does not clear _executingNodeProgress', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
fireProgress('job-1', '1', 'wf-active', 5, 10)
|
||||
expect(store._executingNodeProgress).not.toBeNull()
|
||||
|
||||
const handler = apiEventHandlers.get('executing')
|
||||
if (!handler) throw new Error('executing handler not bound')
|
||||
handler(
|
||||
new CustomEvent('executing', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
node: '2',
|
||||
display_node: '2',
|
||||
workflow_id: 'wf-other'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store._executingNodeProgress).not.toBeNull()
|
||||
})
|
||||
|
||||
it('execution_cached from a non-active workflow does not mark active job nodes', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const startHandler = apiEventHandlers.get('execution_start')
|
||||
if (!startHandler) throw new Error('execution_start handler not bound')
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0, workflow_id: 'wf-active' }
|
||||
})
|
||||
)
|
||||
|
||||
const cachedHandler = apiEventHandlers.get('execution_cached')
|
||||
if (!cachedHandler) throw new Error('execution_cached handler not bound')
|
||||
cachedHandler(
|
||||
new CustomEvent('execution_cached', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-other',
|
||||
nodes: ['n1', 'n2']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJob?.nodes).toEqual({})
|
||||
})
|
||||
|
||||
it('executed from a non-active workflow does not mark active job nodes', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const startHandler = apiEventHandlers.get('execution_start')
|
||||
if (!startHandler) throw new Error('execution_start handler not bound')
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0, workflow_id: 'wf-active' }
|
||||
})
|
||||
)
|
||||
|
||||
const executedHandler = apiEventHandlers.get('executed')
|
||||
if (!executedHandler) throw new Error('executed handler not bound')
|
||||
executedHandler(
|
||||
new CustomEvent('executed', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
node: 'n1',
|
||||
display_node: 'n1',
|
||||
workflow_id: 'wf-other',
|
||||
output: {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJob?.nodes['n1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('execution_error from a non-active workflow does not clear active job state but still clears the errored job initializing flag', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const startHandler = apiEventHandlers.get('execution_start')
|
||||
if (!startHandler) throw new Error('execution_start handler not bound')
|
||||
startHandler(
|
||||
new CustomEvent('execution_start', {
|
||||
detail: { prompt_id: 'job-1', timestamp: 0, workflow_id: 'wf-active' }
|
||||
})
|
||||
)
|
||||
|
||||
store.initializingJobIds = new Set(['job-other'])
|
||||
|
||||
const errorHandler = apiEventHandlers.get('execution_error')
|
||||
if (!errorHandler) throw new Error('execution_error handler not bound')
|
||||
errorHandler(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: 'job-other',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-other',
|
||||
node_id: 'n1',
|
||||
node_type: 'X',
|
||||
executed: [],
|
||||
exception_message: 'oops',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
expect(store.initializingJobIds.has('job-other')).toBe(false)
|
||||
expect(useExecutionErrorStore().lastExecutionError).toBeNull()
|
||||
})
|
||||
|
||||
it('revokes preview when node transitions pending -> running', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const pendingNodes: Record<string, NodeProgressState> = {
|
||||
n1: {
|
||||
value: 0,
|
||||
max: 10,
|
||||
state: 'pending',
|
||||
node_id: 'n1',
|
||||
prompt_id: 'job-1',
|
||||
display_node_id: 'n1'
|
||||
}
|
||||
}
|
||||
fireProgressState('job-1', pendingNodes, 'wf-active')
|
||||
mockRevokePreviewsByExecutionId.mockClear()
|
||||
|
||||
const runningNodes: Record<string, NodeProgressState> = {
|
||||
n1: { ...pendingNodes.n1, state: 'running', value: 1 }
|
||||
}
|
||||
fireProgressState('job-1', runningNodes, 'wf-active')
|
||||
|
||||
expect(mockRevokePreviewsByExecutionId).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - progress_text startup guard', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
@@ -447,6 +923,7 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
nodeId: string
|
||||
text: string
|
||||
prompt_id?: string
|
||||
workflow_id?: string
|
||||
}) {
|
||||
const handler = apiEventHandlers.get('progress_text')
|
||||
if (!handler) throw new Error('progress_text handler not bound')
|
||||
@@ -456,6 +933,7 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
mockActiveWorkflow.current = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
@@ -488,6 +966,50 @@ describe('useExecutionStore - progress_text startup guard', () => {
|
||||
|
||||
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
|
||||
})
|
||||
|
||||
it('skips progress_text whose workflow_id mismatches active workflow', async () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn(() => mockNode) }
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
fireProgressText({
|
||||
nodeId: '1',
|
||||
text: 'warming up',
|
||||
prompt_id: 'job-other',
|
||||
workflow_id: 'wf-other'
|
||||
})
|
||||
|
||||
expect(mockShowTextPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards progress_text whose workflow_id matches active workflow', async () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
const { useCanvasStore } =
|
||||
await import('@/renderer/core/canvas/canvasStore')
|
||||
useCanvasStore().canvas = {
|
||||
graph: { getNodeById: vi.fn(() => mockNode) }
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
fireProgressText({
|
||||
nodeId: '1',
|
||||
text: 'warming up',
|
||||
prompt_id: 'job-1',
|
||||
workflow_id: 'wf-active'
|
||||
})
|
||||
|
||||
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
@@ -767,6 +1289,7 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
mockActiveWorkflow.current = null
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
@@ -878,7 +1401,35 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
})
|
||||
|
||||
describe('executing', () => {
|
||||
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
|
||||
it('clears _executingNodeProgress when workflow_id matches the active workflow', () => {
|
||||
mockActiveWorkflow.current = {
|
||||
activeState: { id: 'wf-active' },
|
||||
path: '/wf-active.json'
|
||||
}
|
||||
fire('execution_start', {
|
||||
prompt_id: 'job-1',
|
||||
timestamp: 0,
|
||||
workflow_id: 'wf-active'
|
||||
})
|
||||
store._executingNodeProgress = {
|
||||
value: 1,
|
||||
max: 2,
|
||||
prompt_id: 'job-1',
|
||||
node: '1'
|
||||
}
|
||||
|
||||
fire('executing', {
|
||||
prompt_id: 'job-1',
|
||||
node: '1',
|
||||
display_node: '1',
|
||||
workflow_id: 'wf-active'
|
||||
})
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
})
|
||||
|
||||
it('clears _executingNodeProgress when ownership is unresolvable (legacy fallback)', () => {
|
||||
mockActiveWorkflow.current = null
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
store._executingNodeProgress = {
|
||||
value: 1,
|
||||
@@ -887,10 +1438,13 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
node: '1'
|
||||
}
|
||||
|
||||
fire('executing', null)
|
||||
fire('executing', {
|
||||
prompt_id: 'job-1',
|
||||
node: '1',
|
||||
display_node: '1'
|
||||
})
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
expect(store.activeJobId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type {
|
||||
ExecutedWsMessage,
|
||||
ExecutingWsMessage,
|
||||
ExecutionCachedWsMessage,
|
||||
ExecutionErrorWsMessage,
|
||||
ExecutionInterruptedWsMessage,
|
||||
@@ -247,22 +248,32 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionIdToLocatorCache.clear()
|
||||
executionErrorStore.clearAllErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
const jobId = e.detail.prompt_id
|
||||
queuedJobs.value[jobId] ??= { nodes: {} }
|
||||
clearInitializationByJobId(jobId)
|
||||
|
||||
// Ensure path mapping exists — execution_start can arrive via WebSocket
|
||||
// before the HTTP response from queuePrompt triggers storeJob.
|
||||
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
|
||||
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
|
||||
if (!jobIdToSessionWorkflowPath.value.has(jobId)) {
|
||||
const path = queuedJobs.value[jobId]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(jobId, path)
|
||||
}
|
||||
|
||||
// Only adopt as the global active job and clear shared UI state when the
|
||||
// starting job belongs to the active workflow. Otherwise a job started
|
||||
// from another tab would steal activeJobId and clobber the active tab's
|
||||
// execution UI.
|
||||
if (!messageMatchesActiveWorkflow(jobId, e.detail.workflow_id)) return
|
||||
|
||||
executionIdToLocatorCache.clear()
|
||||
executionErrorStore.clearAllErrors()
|
||||
activeJobId.value = jobId
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
if (!messageMatchesActiveWorkflow(e.detail.prompt_id, e.detail.workflow_id))
|
||||
return
|
||||
for (const n of e.detail.nodes) {
|
||||
activeJob.value.nodes[n] = true
|
||||
}
|
||||
@@ -272,12 +283,15 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
e: CustomEvent<ExecutionInterruptedWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
|
||||
clearInitializationByJobId(jobId)
|
||||
if (!messageMatchesActiveWorkflow(jobId, e.detail.workflow_id)) return
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
if (!activeJob.value) return
|
||||
if (!messageMatchesActiveWorkflow(e.detail.prompt_id, e.detail.workflow_id))
|
||||
return
|
||||
activeJob.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
@@ -288,22 +302,14 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
})
|
||||
}
|
||||
const jobId = e.detail.prompt_id
|
||||
if (!messageMatchesActiveWorkflow(jobId, e.detail.workflow_id)) return
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
function handleExecuting(e: CustomEvent<ExecutingWsMessage>): void {
|
||||
const { prompt_id: jobId, workflow_id: messageWorkflowId } = e.detail
|
||||
if (!messageMatchesActiveWorkflow(jobId, messageWorkflowId)) return
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
if (!activeJob.value) return
|
||||
|
||||
// Update the executing nodes list
|
||||
if (typeof e.detail !== 'string') {
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
}
|
||||
activeJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,43 +341,92 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
const { nodes, prompt_id: jobId, workflow_id: messageWorkflowId } = e.detail
|
||||
const isActiveWorkflowMessage = messageMatchesActiveWorkflow(
|
||||
jobId,
|
||||
messageWorkflowId
|
||||
)
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !previousForJob[nodeId]) {
|
||||
// This node just started executing, revoke its previews
|
||||
// Note that we're doing the *actual* node id instead of the display node id
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
// within an expanded graph starts executing.
|
||||
const { revokePreviewsByExecutionId } = useNodeOutputStore()
|
||||
revokePreviewsByExecutionId(nodeId)
|
||||
if (isActiveWorkflowMessage) {
|
||||
const { revokePreviewsByExecutionId } = useNodeOutputStore()
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (
|
||||
nodeState.state === 'running' &&
|
||||
previousForJob[nodeId]?.state !== 'running'
|
||||
) {
|
||||
revokePreviewsByExecutionId(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
nodeProgressStatesByJob.value = {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
}
|
||||
evictOldProgressJobs()
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
if (isActiveWorkflowMessage) {
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a WebSocket execution message belongs to the
|
||||
* currently active workflow tab. Used to gate writes to the global
|
||||
* "current execution" mirror so a job initiated from another open
|
||||
* workflow cannot leak its progress into the active one.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `workflow_id` carried on the WS message (when backend supports it).
|
||||
* 2. {@link jobIdToWorkflowId} mapping populated when the job was queued
|
||||
* from this tab.
|
||||
* 3. {@link jobIdToSessionWorkflowPath} mapping (path-based fallback).
|
||||
*
|
||||
* When the workflow cannot be resolved at all (e.g. job queued in a
|
||||
* different browser session), the message is treated as belonging to
|
||||
* the active workflow to preserve current behaviour for the existing
|
||||
* single-tab common case.
|
||||
*/
|
||||
function messageMatchesActiveWorkflow(
|
||||
jobId: JobId,
|
||||
messageWorkflowId: string | undefined
|
||||
): boolean {
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (!activeWorkflow) return true
|
||||
|
||||
const activeId =
|
||||
activeWorkflow.activeState?.id ?? activeWorkflow.initialState?.id ?? null
|
||||
|
||||
if (messageWorkflowId && activeId) {
|
||||
return messageWorkflowId === activeId
|
||||
}
|
||||
|
||||
const mappedId = jobIdToWorkflowId.value.get(jobId)
|
||||
if (mappedId && activeId) return mappedId === activeId
|
||||
|
||||
const mappedPath = jobIdToSessionWorkflowPath.value.get(jobId)
|
||||
if (mappedPath && activeWorkflow.path) {
|
||||
return mappedPath === activeWorkflow.path
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
const { prompt_id: jobId, workflow_id: messageWorkflowId } = e.detail
|
||||
if (!messageMatchesActiveWorkflow(jobId, messageWorkflowId)) return
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
@@ -393,17 +448,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
error: e.detail.exception_message
|
||||
})
|
||||
|
||||
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
|
||||
if (handleCloudValidationError(e.detail)) return
|
||||
}
|
||||
|
||||
// Service-level errors (e.g. "Job has stagnated") have no associated node.
|
||||
// Route them as job errors
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
executionErrorStore.lastExecutionError = e.detail
|
||||
clearInitializationByJobId(e.detail.prompt_id)
|
||||
if (!messageMatchesActiveWorkflow(e.detail.prompt_id, e.detail.workflow_id))
|
||||
return
|
||||
|
||||
executionErrorStore.lastExecutionError = e.detail
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
|
||||
@@ -413,6 +467,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return false
|
||||
|
||||
clearInitializationByJobId(detail.prompt_id)
|
||||
if (!messageMatchesActiveWorkflow(detail.prompt_id, detail.workflow_id))
|
||||
return true
|
||||
|
||||
resetExecutionState(detail.prompt_id)
|
||||
executionErrorStore.lastPromptError = {
|
||||
type: detail.exception_type ?? 'error',
|
||||
@@ -431,6 +488,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!result) return false
|
||||
|
||||
clearInitializationByJobId(detail.prompt_id)
|
||||
if (!messageMatchesActiveWorkflow(detail.prompt_id, detail.workflow_id))
|
||||
return true
|
||||
|
||||
resetExecutionState(detail.prompt_id)
|
||||
|
||||
if (result.kind === 'nodeErrors') {
|
||||
@@ -519,14 +579,27 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleProgressText(e: CustomEvent<ProgressTextWsMessage>) {
|
||||
const { nodeId, text, prompt_id } = e.detail
|
||||
const { nodeId, text, prompt_id, workflow_id } = e.detail
|
||||
if (!text || !nodeId) return
|
||||
|
||||
// Filter: only accept progress for the active prompt
|
||||
if (prompt_id && activeJobId.value && prompt_id !== activeJobId.value)
|
||||
return
|
||||
// Prefer the workflow-ownership gate when ownership can be resolved
|
||||
// (workflow_id on the message, or a registered mapping). Only fall back
|
||||
// to the legacy active-prompt guard when ownership is unresolvable;
|
||||
// otherwise activeJobId pointing at a different workflow's job would
|
||||
// incorrectly drop messages for the visible workflow.
|
||||
if (prompt_id) {
|
||||
const canResolveWorkflow =
|
||||
Boolean(workflow_id) ||
|
||||
jobIdToWorkflowId.value.has(prompt_id) ||
|
||||
jobIdToSessionWorkflowPath.value.has(prompt_id)
|
||||
|
||||
if (canResolveWorkflow) {
|
||||
if (!messageMatchesActiveWorkflow(prompt_id, workflow_id)) return
|
||||
} else if (activeJobId.value && prompt_id !== activeJobId.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle execution node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
if (!currentId) return
|
||||
const node = canvasStore.canvas?.graph?.getNodeById(currentId)
|
||||
|
||||
Reference in New Issue
Block a user