Compare commits

..

9 Commits

Author SHA1 Message Date
Glary-Bot
a67e3dbce9 test: tighten active-workflow gating coverage
Address two coverage nitpicks from review:

- The 'executing clears _executingNodeProgress' test in the
  WebSocket-event-handlers suite did not reset mockActiveWorkflow.current
  in beforeEach and did not pass workflow_id, so it relied on the
  hoisted mock leaking from earlier suites and only ever exercised the
  unresolvable-ownership fallback. Reset the mock per-test, set an
  active workflow explicitly with workflow_id: 'wf-active', and add a
  separate test for the legacy unresolvable-ownership fallback.

- The 'execution_error from a non-active workflow' test only proved the
  active job's state was untouched. It now also asserts the errored
  job's initializing flag is cleared (the per-job bookkeeping that runs
  unconditionally) and that executionErrorStore.lastExecutionError stays
  null (proving the global gate held).
2026-05-05 07:09:25 +00:00
Glary-Bot
7e75921fb5 fix: gate execution_cached, executed, execution_error and revoke transitions
Three handlers and one revocation rule were missed in the first gating
pass and could still let a non-active workflow's events bleed into the
active workflow's UI:

- handleExecutionCached and handleExecuted mutated activeJob.value.nodes
  unconditionally, skewing executionProgress / nodesExecuted on the
  visible workflow when a background workflow emitted those events.
- handleExecutionError, handleServiceLevelError and handleCloudValidationError
  called resetExecutionState and wrote executionErrorStore.* unconditionally,
  meaning a background error wiped activeJobId and node progress for the
  visible workflow. Initialization clearing for the errored job still
  runs in every case.
- handleProgressState revoked previews only when a node was first seen
  (!previousForJob[nodeId]). Once progress_state begins emitting pending
  entries that node is already 'seen', so the pending->running
  transition never revoked. Switch to checking previous state.

groupNode forwarder: reinstate the legacy string detail path in the
'executing' id-extractor so callers still dispatching the pre-change
string payload keep bubbling execution up to the group node.

4 new unit tests cover: execution_cached gated, executed gated,
execution_error gated, and pending->running preview revocation.
2026-05-05 07:01:04 +00:00
Glary-Bot
48b953be31 feat: gate execution lifecycle handlers and executing event by active workflow
Extend the active-workflow gate to lifecycle events that previously
operated globally:

- handleExecutionStart: only adopts activeJobId / clears shared UI state
  when the starting job belongs to the active workflow. Per-job
  bookkeeping (queuedJobs, jobIdToSessionWorkflowPath, initialization
  clearing) still runs for every job.
- handleExecutionSuccess / handleExecutionInterrupted: only call
  resetExecutionState when the terminating job belongs to the active
  workflow. Initialization clearing for the terminated job still runs.
- handleExecuting: now receives the full ExecutingWsMessage from the
  api dispatcher (instead of just NodeId) and gates _executingNodeProgress
  clearing on workflow ownership.

To support handleExecuting gating, drop the ApiToEventType override that
narrowed the executing event to NodeId, forward the full payload through
api.dispatchCustomEvent, and update the groupNode forwarding wrapper to
extract display_node/node from the new object detail and synthesise a
matching ExecutingWsMessage when re-dispatching.

5 new tests cover the cross-workflow cases: execution_start from a
non-active workflow does not steal activeJobId, execution_success and
execution_interrupted from a non-active workflow do not clear the
active job's state, executing from a non-active workflow does not
clear _executingNodeProgress, and execution_start from the active
workflow still adopts activeJobId.

Eventual-consistency cleanup of non-active terminal jobs (which used to
be handled by the dropped polling/eviction code) will be implemented in
a follow-up PR using a reactive watcher pattern over a derived
finishedJobs set.
2026-05-05 06:49:51 +00:00
Glary-Bot
72877c8c1a feat: scope progress events and UI state by active workflow
When multiple workflow tabs are open and a job initiated from one tab
sends progress messages, those messages can leak into the active tab's
canvas because the global nodeProgressStates mirror, _executingNodeProgress,
and progress_text preview state are written unconditionally.

Add an optional workflow_id field to the WS schema for execution-related
messages (progress, progress_state, executing, executed, progress_text,
execution_start/success/cached/interrupted/error, NodeProgressState),
and gate handleProgressState, handleProgress, and handleProgressText on
whether the incoming message belongs to the currently active workflow.

Resolution order for ownership:
1. workflow_id carried on the WS message (when backend supports it).
2. jobIdToWorkflowId mapping populated when the job was queued from this tab.
3. jobIdToSessionWorkflowPath mapping (path-based fallback).

When ownership is unresolvable (e.g. job queued in a different browser
session), the message is treated as belonging to the active workflow to
preserve current single-tab behaviour. Per-job state
(nodeProgressStatesByJob) is always written regardless of ownership so
the source of truth covers every workflow's jobs.

handleProgressText prefers the workflow gate over the legacy activeJobId
guard when ownership can be resolved, since activeJobId is global and may
point at a different workflow's job — falling through to the legacy
guard only when ownership is genuinely unresolvable.

12 new unit tests cover workflow_id match/mismatch, both fallback
resolution paths, default-to-legacy when unresolvable, no-active-workflow
short-circuit, per-job state always updating, preview revocation gating,
_executingNodeProgress gating, and progress_text gating.

Eventual-consistency fallback for dropped terminal WebSocket messages
will be addressed in a follow-up PR using a reactive watcher pattern
rather than queue polling, per design discussion.
2026-05-04 22:43:17 +00:00
pythongosssss
5fbcea6b27 test: add test for workflow delete confirmation (#11780)
## Summary

Adds tests for the `Comfy.Workflow.ConfirmDelete` setting

## Changes

- **What**: 
- ensures dialog does/doesnt appear based on the setting

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11780-test-add-test-for-workflow-delete-confirmation-3526d73d36508134a3cdf0e908b95919)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 21:51:50 +00:00
Comfy Org PR Bot
ac36dc47a4 docs: Weekly Documentation Update (#11465)
## Summary

Fixed two minor documentation inaccuracies found during comprehensive
documentation audit:
- Corrected outdated "Lodash" reference to "Utility Functions" in unit
testing guide
- Updated package manager command from `npx` to `pnpm dlx` in Playwright
skill documentation

## Changes Made

### Documentation Fixes

#### docs/testing/unit-testing.md:150
- **Before**: `## Mocking Lodash Functions`
- **After**: `## Mocking Utility Functions`
- **Reason**: The section describes mocking `es-toolkit/compat`
functions, not Lodash. The project uses es-toolkit as stated in
AGENTS.md line 158 and docs/guidance/typescript.md line 60.

#### .claude/skills/writing-playwright-tests/SKILL.md:117
- **Before**: `npx playwright show-trace trace.zip`
- **After**: `pnpm dlx playwright show-trace trace.zip`
- **Reason**: Project standardizes on pnpm, explicitly avoiding npx per
AGENTS.md line 42: "use `pnpx` or `pnpm dlx` — never `npx`"

## Audit Summary

Comprehensive audit verified accuracy of:
-  Core documentation (CLAUDE.md, AGENTS.md, README.md)
-  All docs/**/*.md files (40+ files including ADRs, testing guides,
architecture docs)
-  All README files throughout repository (21 files)
-  All .claude/commands/*.md files (8 files)
-  Code examples and API references
-  File structure references (verified src/router.ts, src/i18n.ts,
src/main.ts, config files exist)
-  Package dependencies (es-toolkit ^1.39.9 confirmed)
-  Script commands (pnpm test:unit, pnpm test:browser:local, etc.)
-  External resource links
-  ADR index and dates

All other documentation remains accurate and up-to-date as of
2026-05-04.

## Review Notes

This PR contains only two trivial corrections to terminology/commands.
No functional changes, no code changes, no breaking changes. The
documentation audit found the codebase documentation to be in excellent
condition overall.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-04 21:37:52 +00:00
Christian Byrne
aef71852f0 feat: add demo pages with Arcade embeds at /demos/{slug} (#11436)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a demo pages system to the website that embeds Arcade interactive
walkthroughs at `comfy.org/demos/{slug}`. These pages will be linked
from welcome/lifecycle emails via Customer.io.

- Adds `/demos/image-to-video` and `/demos/workflow-templates` as the
first two demos
- Follows the existing `customers/[slug].astro` pattern exactly
(config-driven `getStaticPaths()`)
- Full SEO: OG/Twitter cards, HowTo + LearningResource + BreadcrumbList
JSON-LD schemas
- GEO: AI crawler directives in robots.txt, crawlable transcript
alongside iframe
- A11y: iframe title, sr-only transcript, aria-expanded toggle, noscript
fallback
- Email optimization: 1200x630 OG images, SSG pre-rendered, preconnect
to Arcade CDN
- Full zh-CN localization
- Library index stub at /demos for future expansion
- Automatic sitemap inclusion

## Architecture

Adding a new demo = adding one object to `src/config/demos.ts`.

## Note

OG images are tiny placeholders — replace with real 1200x630 screenshots
before go-live.

## Screenshots

![Demo detail page showing Arcade embed with full design
system](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565133-5566bf1b-e965-437d-b21f-89e7a751f883.png)

![Demo library index - Coming Soon
stub](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d4e44d93c258f779ed62667c7924810f9ae7f20f0c9105acd9c3f86f63816bd1/pr-images/1776645565461-0e334640-13e6-4554-ad6e-b3843e107572.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11436-feat-add-demo-pages-with-Arcade-embeds-at-demos-slug-3486d73d365081abbd72e02bf497a43a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:53:36 -07:00
Dante
94b570a177 test: rename misnamed Mixpanel test and cover the actual provider class (#11749)
## Summary

The existing \`MixpanelTelemetryProvider.test.ts\` was misnamed: it only
tested \`getExecutionContext\` from \`../../utils/getExecutionContext\`,
never the provider class itself — provider coverage sat at **0%**
despite a 239-line test file living next to it.

This PR:

1. **Renames** the existing test file to
\`src/platform/telemetry/utils/getExecutionContext.test.ts\` (co-located
with the source it actually tests). Updates its relative import to
\`./getExecutionContext\`.
2. **Adds** a fresh
\`src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts\`
covering the provider class.

Lifts provider coverage from **0% → 81.1%** lines (functions 73.5%,
branches 88.5%).

## Test Coverage (new tests)

Constructor / initialization:
- Without \`mixpanel_token\`, warns and disables itself; subsequent
\`trackXxx\` calls are no-ops.
- With \`mixpanel_token\`, dynamically imports mixpanel-browser, calls
\`init\`, and after \`loaded()\` fires identifies users via
\`onUserResolved\`.

Queueing semantics:
- Events fired before \`loaded()\` are queued and flushed in order once
Mixpanel reports ready.

Filtering:
- Events listed in the default \`disabledEvents\` set (e.g.
\`workflow_opened\`) are suppressed.

Direct dispatchers (parameterized \`it.each\`):
- 16 \`trackXxx\` methods covered: signup/auth/login, subscription
lifecycle, credit topup events, template lifecycle, workflow
imported/saved, default-view, enter-linear, share-flow, execution
success/error.
- \`trackApiCreditTopupButtonPurchaseClicked\` payload includes
\`credit_amount\`.
- \`trackEmailVerification\` dispatches the matching
\`USER_EMAIL_VERIFY_*\` event for each stage.
- \`trackSubscription\` maps \`'modal_opened'\` and
\`'subscribe_clicked'\` to their distinct events.
- \`trackRunButton\` populates \`RunButtonProperties\` from the
execution context.
- \`trackWorkflowExecution\` consumes the latest \`trigger_source\` from
\`trackRunButton\`, then resets it to \`'unknown'\`.

Survey:
- On \`'submitted'\`, normalized properties are written to
\`Mixpanel.people\`.
- On \`'opened'\`, \`Mixpanel.people\` is not touched.

Topup delegation:
- \`startTopupTracking\`, \`clearTopupTracking\`,
\`checkForCompletedTopup\` all forward to the \`topupTracker\` utility.

## Testing

\`\`\`bash
pnpm vitest run
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
pnpm vitest run src/platform/telemetry/utils/getExecutionContext.test.ts
\`\`\`

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11749-test-rename-misnamed-Mixpanel-test-and-cover-the-actual-provider-class-3516d73d365081609c54f34bd2d8b00d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-04 14:52:03 -07:00
Comfy Org PR Bot
846412af17 [chore] Update Ingest API types from cloud@758732f (#11479)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 758732f
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: skishore23 <178779+skishore23@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-04 21:37:31 +00:00
31 changed files with 3476 additions and 295 deletions

View File

@@ -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`

View 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()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -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

View 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>

View 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>

View 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>

View 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>

View 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]
}

View File

@@ -11,6 +11,7 @@ const baseRoutes = {
about: '/about',
careers: '/careers',
customers: '/customers',
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact'

View File

@@ -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': '接下来看什么?'

View File

@@ -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 && (

View 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>

View 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>

View 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>

View 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>

View 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)
})
})

View File

@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
})
```
## Mocking Lodash Functions
## Mocking Utility Functions
Mocking utility functions like debounce:

View File

@@ -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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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)
})
})

View 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)
})
})
})

View File

@@ -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({

View File

@@ -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':

View File

@@ -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()
})
})

View File

@@ -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)