Compare commits

..

10 Commits

Author SHA1 Message Date
huang47
8405717a6f test: use testing pinia in critical store specs 2026-07-06 21:14:45 -07:00
huang47
59eb3ed538 test: remove redundant about panel comment 2026-07-06 21:09:47 -07:00
huang47
6533961422 test: remove default-fallback change detector from queueStore 2026-07-06 12:04:53 -07:00
huang47
686bb8a9fe test: split create and update secret specs for failure isolation 2026-07-03 15:18:31 -07:00
huang47
c52d212796 test: replace process listener and unsafe casts per review feedback
- apiKeyAuthStore: remove process.once unhandledRejection listener; assert
  the error through toastErrorHandler which now receives it via .catch()
- executionError: replace double assertion with fromPartial<ComfyWorkflow>
- nodeBookmarkStore: replace satisfies+cast fixture helpers with
  fromPartial<ComfyNodeDefImpl> to keep type-checking without unsafe casts
- queueStore: replace fromAny<CustomEvent> with real CustomEvent instances;
  replace remaining fromAny<TaskOutput> with fromPartial or undefined
- queueStore.loadWorkflow: drop fromAny, use fromPartial<TaskOutput> and
  undefined where null was passed to TaskItemImpl
2026-07-03 09:24:11 -07:00
huang47
f916a3f8a7 fix(apiKeyAuthStore): handle rejected promise from initializeUserFromApiKey
Route the unhandled rejection from the async watcher through reportError
so failures during user initialization are surfaced via the toast handler
instead of propagating as unhandled rejections.
2026-07-03 09:23:38 -07:00
huang47
ccd33bb505 test: cover critical stores 2026-07-02 09:09:54 -07:00
Benjamin Lu
2ec2a0e091 feat: attribute payment intent through paywall, checkout, and top-up telemetry (#13363)
## Summary

Answers "why did this user want to pay?" by capturing the triggering
product moment at every paywall/upsell entry point and carrying it
through checkout and success telemetry.

## Changes

- **What**:
- Widen `SubscriptionDialogReason` from 4 coarse values to 13 grounded
intent sources (`subscribe_to_run`, `upgrade_to_add_credits`,
`invite_member_upsell`, `settings_billing_panel`, etc.)
- Fire `app:subscription_required_modal_opened` from
`useSubscriptionDialog` (the choke point all dialog variants pass
through) — the workspace/unified path previously emitted nothing; remove
the now-duplicate emitters in `useSubscription` and
`usePricingTableUrlLoader`
- Add `payment_intent_source` to
`BeginCheckoutMetadata`/`SubscriptionSuccessMetadata`, threaded via the
existing `reason` prop: dialog → `PricingTable` →
`performSubscriptionCheckout` → pending-attempt record, so legacy
`app:monthly_subscription_succeeded` carries intent alongside
`checkout_attempt_id`
- Fire `begin_checkout` on the workspace checkout path
(`useSubscriptionCheckout`, personal + team confirm) and the team
deep-link util — both previously emitted nothing; `tier` widened to
`TierKey | 'team'`
- Implement `trackBeginCheckout` in `PostHogTelemetryProvider` (was
GTM/host-only, so `begin_checkout` never reached PostHog)
- Thread `showSubscriptionDialog(options)` through the billing-context
adapters and pass a reason at ~14 call sites; add `source` to
`app:add_api_credit_button_clicked`

## Review Focus

- `modal_opened` now fires once per dialog actually shown, so a
free-tier user clicking Upgrade emits two events (free-tier dialog, then
pricing table) where the legacy path emitted one
- Intent is threaded explicitly via props/params rather than shared
state; `useSubscriptionCheckout` gained an optional second parameter
2026-07-02 03:11:21 +00:00
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.

## Changes

- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.

## Review Focus

Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.

## Notes from the #13289 review (left as-is here, open to discussion)

Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
jaeone94
9e5fb67b76 Show app mode run validation warning (#12557)
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.

## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.

The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.

## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`

## Screenshots

<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
2026-07-01 15:24:45 +09:00
84 changed files with 2707 additions and 1515 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET = -144
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
>
<slot />
</li>

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,6 +34,10 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -92,13 +96,19 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,6 +172,9 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -0,0 +1,106 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
)
.toBe(1)
})

View File

@@ -3,6 +3,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="seeErrors"
@click="viewErrorsInGraph"
>
{{
appMode
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}

View File

@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
}
</script>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
@@ -75,9 +76,10 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
*/
showSubscriptionDialog: () => void
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
}
export interface BillingState {

View File

@@ -7,6 +7,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
}
return {

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
}
return {

View File

@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
return
}

View File

@@ -0,0 +1,105 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -25,6 +25,6 @@ function handleClose() {
}
function handleSubscribe() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
}
</script>

View File

@@ -140,7 +140,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
// Shows loading affordances
@@ -169,7 +172,10 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
false
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
)
})
@@ -180,7 +186,8 @@ describe('CloudSubscriptionRedirectView', () => {
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly'
'yearly',
{ paymentIntentSource: 'deep_link' }
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()

View File

@@ -94,7 +94,9 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle)
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
return
}
@@ -112,7 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
})
}
}, reportError)

View File

@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked()
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable()
showPricingTable({ reason: 'upgrade_to_add_credits' })
}
async function handleWindowFocus() {

View File

@@ -5,6 +5,8 @@ import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
@@ -15,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent() {
function renderComponent(props?: { reason?: PaymentIntentSource }) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -23,6 +25,7 @@ function renderComponent() {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -43,4 +46,18 @@ describe('FreeTierDialogContent', () => {
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
it('keeps the generic copy for intent reasons outside the credits variants', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'subscribe_to_run' })
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('swaps to the out-of-credits copy without the refresh line', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'out_of_credits' })
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!reason || reason === 'subscription_required'"
v-if="!isCreditsBlockedVariant"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,10 +65,7 @@
</p>
<p
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -88,7 +85,7 @@
@click="$emit('upgrade')"
>
{{
reason === 'out_of_credits' || reason === 'top_up_blocked'
isCreditsBlockedVariant
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -103,12 +100,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
reason?: SubscriptionDialogReason
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
defineEmits<{
@@ -129,4 +126,10 @@ const formattedRenewalDate = computed(() => {
})
const freeTierCredits = computed(() => getTierCredits('free'))
// Only these two variants replace the generic free-tier copy; any other
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
const isCreditsBlockedVariant = computed(
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
)
</script>

View File

@@ -261,6 +261,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -341,6 +342,7 @@ describe('PricingTable', () => {
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('should use the latest userId value when it changes after mount', async () => {
@@ -366,6 +368,7 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

@@ -277,13 +277,19 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import {
recordPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { useAuthStore } from '@/stores/authStore'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
@@ -321,6 +327,10 @@ interface PricingTierConfig {
isPopular?: boolean
}
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
@@ -463,16 +473,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
const beginCheckoutMetadata = userId.value
? {
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change' as const,
...(reason ? { payment_intent_source: reason } : {}),
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
}
: null
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
@@ -487,29 +498,39 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
const didOpenPortal = await accessBillingPortal()
if (didOpenPortal && beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
}
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
recordPendingSubscriptionCheckoutAttempt({
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
payment_intent_source: reason,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
if (beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
beginCheckoutMetadata,
pendingAttempt
)
)
}
}
} else {
await performSubscriptionCheckout(
tierKey,
currentBillingCycle.value,
true
)
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
paymentIntentSource: reason
})
}
} finally {
isLoading.value = false

View File

@@ -56,7 +56,7 @@ const handleSubscribe = () => {
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_now_button' })
}
onBeforeUnmount(() => {

View File

@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscribe_to_run' })
}
</script>

View File

@@ -48,7 +48,9 @@
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
@click="
showSubscriptionDialog({ reason: 'settings_billing_panel' })
"
>
{{ $t('subscription.upgradePlan') }}
</Button>

View File

@@ -33,7 +33,11 @@
</i18n-t>
</div>
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<PricingTable
:reason
class="flex-1"
@choose-team-workspace="handleChooseTeam"
/>
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -157,11 +161,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
onChooseTeam?: () => void
}>()

View File

@@ -24,7 +24,9 @@ export function useAccountPreconditionDialog() {
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog()
void dialogService.showSubscriptionRequiredDialog({
reason: 'subscription_required'
})
return
case 'credits':
void dialogService.showTopUpCreditsDialog({

View File

@@ -55,12 +55,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -96,9 +90,6 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -150,7 +141,6 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
@@ -161,7 +151,6 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -230,7 +219,6 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,7 +7,6 @@ import {
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -62,7 +61,6 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

View File

@@ -15,7 +15,7 @@ import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
@@ -237,14 +237,7 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
void showSubscriptionRequiredDialog(options)
}
@@ -277,7 +270,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'subscription_required' })
}
}

View File

@@ -39,15 +39,23 @@ vi.mock('@/stores/commandStore', () => ({
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
const {
mockIsCloud,
mockTrackHelpResourceClicked,
mockTrackAddApiCreditButtonClicked
} = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn()
mockTrackHelpResourceClicked: vi.fn(),
mockTrackAddApiCreditButtonClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
? {
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
}
: null
}))
@@ -69,6 +77,9 @@ describe('useSubscriptionActions', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
source: 'settings_billing_panel'
})
})
})

View File

@@ -21,6 +21,9 @@ export function useSubscriptionActions() {
})
const handleAddApiCredits = () => {
telemetry?.trackAddApiCreditButtonClicked({
source: 'settings_billing_panel'
})
void dialogService.showTopUpCreditsDialog()
}

View File

@@ -5,8 +5,10 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
@@ -60,10 +62,15 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -80,6 +87,7 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -198,6 +206,51 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('tracks modal_opened with the caller reason and current tier', () => {
mockTier.value = 'STANDARD'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upgrade_to_add_credits' })
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
current_tier: 'standard',
reason: 'upgrade_to_add_credits'
})
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'subscribe_to_run' })
)
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('show', () => {
@@ -235,6 +288,20 @@ describe('useSubscriptionDialog', () => {
expect.objectContaining({ key: 'subscription-required' })
)
})
it('tracks modal_opened with the reason for the free-tier dialog', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'out_of_credits' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -4,6 +4,8 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -11,14 +13,8 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -38,6 +34,17 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
// Fired here — the choke point every paywall/pricing dialog variant passes
// through — so both the legacy and workspace billing paths emit it.
function trackModalOpened(reason?: PaymentIntentSource) {
// Resolved lazily to avoid the useBillingContext import cycle (see below).
const { tier } = useBillingContext()
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: tier.value?.toLowerCase(),
reason
})
}
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
@@ -71,6 +78,8 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -167,6 +176,8 @@ export const useSubscriptionDialog = () => {
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackModalOpened(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
@@ -236,7 +247,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable()
showPricingTable({ reason: 'team_upgrade_resume' })
}
} catch {
// sessionStorage may be unavailable

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
recordPendingSubscriptionCheckoutAttempt
} from './subscriptionCheckoutTracker'
const activeProStatus = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as const
describe('subscriptionCheckoutTracker', () => {
beforeEach(() => {
clearPendingSubscriptionCheckoutAttempt()
})
it('round-trips payment_intent_source from attempt to success metadata', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).toMatchObject({
tier: 'pro',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
})
it('omits payment_intent_source when the attempt had none', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).not.toBeNull()
expect(metadata).not.toHaveProperty('payment_intent_source')
})
})

View File

@@ -7,7 +7,12 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -23,7 +28,6 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
@@ -32,22 +36,24 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
interface PendingSubscriptionCheckoutAttempt {
export interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface RecordPendingSubscriptionCheckoutAttemptInput {
interface PendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: CheckoutType
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -168,6 +174,9 @@ const normalizeAttempt = (
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {}),
...(typeof candidate.payment_intent_source === 'string'
? { payment_intent_source: candidate.payment_intent_source }
: {})
}
}
@@ -224,20 +233,27 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
return {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -255,6 +271,21 @@ export const recordPendingSubscriptionCheckoutAttempt = (
return attempt
}
export const recordPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt =>
persistPendingSubscriptionCheckoutAttempt(
createPendingSubscriptionCheckoutAttempt(input)
)
export const withPendingCheckoutAttemptId = (
metadata: BeginCheckoutMetadata,
attempt: PendingSubscriptionCheckoutAttempt
): BeginCheckoutMetadata => ({
...metadata,
checkout_attempt_id: attempt.attempt_id
})
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
@@ -287,6 +318,9 @@ export const consumePendingSubscriptionCheckoutSuccess = (
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
...(attempt.payment_intent_source
? { payment_intent_source: attempt.payment_intent_source }
: {}),
value,
currency: 'USD',
ecommerce: {

View File

@@ -132,13 +132,14 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly', true)
await performSubscriptionCheckout('pro', 'yearly')
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String),
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
@@ -150,6 +151,12 @@ describe('performSubscriptionCheckout', () => {
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
JSON.parse(storedAttempt).attempt_id
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
@@ -186,7 +193,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -203,11 +210,43 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', {
paymentIntentSource: 'out_of_credits'
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
)
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
const pendingAttempt = JSON.parse(storedAttempt)
expect(pendingAttempt).toMatchObject({
payment_intent_source: 'out_of_credits'
})
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
pendingAttempt.attempt_id
)
openSpy.mockRestore()
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
@@ -222,7 +261,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -235,13 +274,14 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new'
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -250,11 +290,18 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
await performSubscriptionCheckout('pro', 'monthly')
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
const storedAttempt = window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
expect(storedAttempt).toBeNull()
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: expect.any(String)
})
)
})
})

View File

@@ -4,12 +4,19 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
persistPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -31,6 +38,11 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -47,10 +59,12 @@ const getCheckoutAttributionForCloud =
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
openInNewTab: boolean = true
options: PerformSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -108,14 +122,29 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
}
if (openInNewTab) {
@@ -123,18 +152,9 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
} else {
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -16,6 +20,12 @@ vi.mock('@/config/comfyApi', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
@@ -43,7 +53,9 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -51,6 +63,14 @@ describe('performTeamSubscriptionCheckout', () => {
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'team',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op_1',
payment_intent_source: 'deep_link'
})
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
@@ -82,6 +102,16 @@ describe('performTeamSubscriptionCheckout', () => {
expect(assignedHref).toBeUndefined()
})
it('does not track begin_checkout when subscribe fails', async () => {
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow('subscribe failed')
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false

View File

@@ -1,10 +1,16 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
import type { BillingCycle } from './subscriptionTierRank'
interface PerformTeamSubscriptionCheckoutOptions {
paymentIntentSource?: PaymentIntentSource
}
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
@@ -22,7 +28,8 @@ import type { BillingCycle } from './subscriptionTierRank'
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
): Promise<void> {
if (!isCloud) return
@@ -33,6 +40,14 @@ export async function performTeamSubscriptionCheckout(
teamCreditStopId
})
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType: 'new',
billingOpId: response.billing_op_id,
paymentIntentSource: options.paymentIntentSource
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import {
createSecret,
deleteSecret,
listSecrets,
updateSecret
} from './secretsApi'
import type { SecretsApiError } from './secretsApi'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
const fetchApi = vi.mocked(api.fetchApi)
const secret = {
id: 'secret-1',
name: 'HF token',
provider: 'huggingface' as const,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
}
function jsonResponse(body: unknown, init: ResponseInit = {}) {
const headers = new Headers(init.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return new Response(JSON.stringify(body), {
status: 200,
...init,
headers
})
}
describe('secretsApi', () => {
beforeEach(() => {
fetchApi.mockReset()
})
it('lists secrets from the API response data field', async () => {
fetchApi.mockResolvedValue(jsonResponse({ data: [secret] }))
await expect(listSecrets()).resolves.toEqual([secret])
expect(fetchApi).toHaveBeenCalledWith('/secrets')
})
it('creates secrets with a JSON payload', async () => {
fetchApi.mockResolvedValue(jsonResponse(secret))
await expect(
createSecret({
name: 'HF token',
secret_value: 'token',
provider: 'huggingface'
})
).resolves.toEqual(secret)
expect(fetchApi).toHaveBeenCalledWith('/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'HF token',
secret_value: 'token',
provider: 'huggingface'
})
})
})
it('updates secrets with a JSON payload', async () => {
fetchApi.mockResolvedValue(jsonResponse(secret))
await expect(
updateSecret('secret-1', { name: 'New name' })
).resolves.toEqual(secret)
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New name' })
})
})
it('deletes secrets without reading a success body', async () => {
fetchApi.mockResolvedValue(new Response(null, { status: 204 }))
await expect(deleteSecret('secret-1')).resolves.toBeUndefined()
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
method: 'DELETE'
})
})
it('throws a typed error for known API error codes', async () => {
fetchApi.mockResolvedValue(
jsonResponse(
{ message: 'Duplicate provider', code: 'DUPLICATE_PROVIDER' },
{ status: 409, statusText: 'Conflict' }
)
)
await expect(listSecrets()).rejects.toMatchObject({
name: 'SecretsApiError',
message: 'Duplicate provider',
status: 409,
code: 'DUPLICATE_PROVIDER'
} satisfies Partial<SecretsApiError>)
})
it('falls back to status text for non-JSON error responses', async () => {
fetchApi.mockResolvedValue(
new Response('not-json', {
status: 500,
statusText: 'Server Error'
})
)
await expect(listSecrets()).rejects.toMatchObject({
message: 'Server Error',
status: 500,
code: undefined
} satisfies Partial<SecretsApiError>)
})
it('ignores unknown API error codes', async () => {
fetchApi.mockResolvedValue(
jsonResponse(
{ message: 'Unexpected', code: 'SOMETHING_ELSE' },
{ status: 400, statusText: 'Bad Request' }
)
)
await expect(listSecrets()).rejects.toMatchObject({
message: 'Unexpected',
status: 400,
code: undefined
} satisfies Partial<SecretsApiError>)
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { getProviderLabel, getProviderLogo } from './providers'
import type { SecretProvider } from './types'
describe('secret providers', () => {
it('returns empty display values when provider is missing', () => {
expect(getProviderLabel(undefined)).toBe('')
expect(getProviderLogo(undefined)).toBeUndefined()
})
it('returns configured labels and logos', () => {
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
})
it('falls back to the provider value for unknown labels', () => {
const provider = 'custom' as SecretProvider
expect(getProviderLabel(provider)).toBe('custom')
expect(getProviderLogo(provider)).toBeUndefined()
})
})

View File

@@ -30,6 +30,39 @@ describe('TelemetryRegistry', () => {
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
})
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
const b: TelemetryProvider = {}
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const metadata = {
user_id: 'user-1',
tier: 'pro' as const,
cycle: 'monthly' as const,
checkout_type: 'new' as const,
payment_intent_source: 'subscribe_to_run' as const
}
registry.trackBeginCheckout(metadata)
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
})
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
const provider: TelemetryProvider = {
trackAddApiCreditButtonClicked: vi.fn()
}
const registry = new TelemetryRegistry()
registry.registerProvider(provider)
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(
provider.trackAddApiCreditButtonClicked
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
})
it('skips providers that do not implement trackSearchQuery', () => {
const empty: TelemetryProvider = {}
const registry = new TelemetryRegistry()

View File

@@ -1,6 +1,7 @@
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -99,8 +100,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
}
trackAddApiCreditButtonClicked(): void {
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.dispatch((provider) =>
provider.trackAddApiCreditButtonClicked?.(metadata)
)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -313,6 +313,42 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures begin_checkout with intent metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackBeginCheckout({
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.BEGIN_CHECKOUT,
{
user_id: 'user-1',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
}
)
})
it('captures add-credit clicks with their source', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'credits_panel' }
)
})
it('captures share attribution events', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

@@ -10,7 +10,9 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -350,8 +352,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName, metadata)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackMonthlySubscriptionSucceeded(

View File

@@ -115,6 +115,17 @@ describe('HostTelemetrySink', () => {
)
})
it('forwards add-credit clicks with their source', () => {
new HostTelemetrySink().trackAddApiCreditButtonClicked({
source: 'avatar_menu'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
{ source: 'avatar_menu' }
)
})
it('does nothing when the host bridge is absent', () => {
delete window.__comfyDesktop2

View File

@@ -10,6 +10,7 @@ import {
import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -126,8 +127,8 @@ export class HostTelemetrySink implements TelemetryProvider {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackAddApiCreditButtonClicked(): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {

View File

@@ -12,12 +12,29 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
import type { AppMode } from '@/utils/appMode'
export type PaymentIntentSource =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
| 'subscribe_to_run'
| 'subscribe_now_button'
| 'upgrade_to_add_credits'
| 'settings_billing_panel'
| 'avatar_menu_plans'
| 'team_members_panel'
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
/**
* Authentication metadata for sign-up tracking
*/
@@ -426,16 +443,23 @@ export interface CheckoutAttributionMetadata {
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
}
export interface AddCreditsClickMetadata {
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
tier: TierKey
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
checkout_attempt_id?: string
billing_op_id?: string
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
}
interface EcommerceItemMetadata {
@@ -457,8 +481,9 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
checkout_attempt_id: string
tier: TierKey
cycle: BillingCycle
checkout_type: 'new' | 'change'
checkout_type: SubscriptionCheckoutType
previous_tier?: TierKey
payment_intent_source?: PaymentIntentSource
value: number
currency: string
ecommerce: EcommerceMetadata
@@ -489,7 +514,7 @@ export interface TelemetryProvider {
metadata?: SubscriptionSuccessMetadata
): void
trackMonthlySubscriptionCancelled?(): void
trackAddApiCreditButtonClicked?(): void
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void

View File

@@ -1,93 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
interface WorkflowFlags {
path: string
isPersisted?: boolean
isModified?: boolean
}
function wf(flags: WorkflowFlags): ComfyWorkflow {
return flags as unknown as ComfyWorkflow
}
function paths(workflows: ComfyWorkflow[]) {
return workflows.map((w) => w.path)
}
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore workflow lists', () => {
it('persistedWorkflows excludes unpersisted and subgraph entries', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json', isPersisted: true }))
store.attachWorkflow(wf({ path: 'b.json', isPersisted: false }))
store.attachWorkflow(wf({ path: 'subgraphs/c.json', isPersisted: true }))
expect(paths(store.persistedWorkflows)).toEqual(['a.json'])
})
it('modifiedWorkflows includes only modified workflows', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json', isModified: true }))
store.attachWorkflow(wf({ path: 'b.json', isModified: false }))
expect(paths(store.modifiedWorkflows)).toEqual(['a.json'])
})
it('bookmarkedWorkflows is empty when nothing is bookmarked', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json' }))
expect(store.bookmarkedWorkflows).toEqual([])
})
it('bookmarkedWorkflows includes only bookmarked workflows', async () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json' }))
store.attachWorkflow(wf({ path: 'b.json' }))
await useWorkflowBookmarkStore().setBookmarked('a.json', true)
expect(paths(store.bookmarkedWorkflows)).toEqual(['a.json'])
})
it('openedWorkflowIndexShift returns null when no workflow is active', () => {
const store = useWorkflowStore()
store.attachWorkflow(wf({ path: 'a.json' }), 0)
expect(store.openedWorkflowIndexShift(1)).toBeNull()
})
})

View File

@@ -1,93 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Subgraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
const SUBGRAPH_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore node locator translation', () => {
it('treats a node as a root-graph node when no subgraph is active', () => {
const store = useWorkflowStore()
expect(store.nodeIdToNodeLocatorId(toNodeId(5))).toBe('5')
})
it('prefixes the locator with an explicit subgraph uuid', () => {
const store = useWorkflowStore()
const subgraph = { id: SUBGRAPH_UUID } as unknown as Subgraph
expect(store.nodeIdToNodeLocatorId(toNodeId(5), subgraph)).toBe(
`${SUBGRAPH_UUID}:5`
)
})
it('derives a locator from a node based on whether its graph is a subgraph', () => {
const store = useWorkflowStore()
const rootNode = { id: toNodeId(7), graph: {} } as unknown as LGraphNode
expect(store.nodeToNodeLocatorId(rootNode)).toBe('7')
const subgraphNode = {
id: toNodeId(7),
graph: { id: SUBGRAPH_UUID, isRootGraph: false }
} as unknown as LGraphNode
expect(store.nodeToNodeLocatorId(subgraphNode)).toBe(`${SUBGRAPH_UUID}:7`)
})
it('extracts the local node id from a locator', () => {
const store = useWorkflowStore()
expect(
store.nodeLocatorIdToNodeId(
createNodeLocatorId(SUBGRAPH_UUID, toNodeId(5))
)
).toBe(toNodeId(5))
expect(
store.nodeLocatorIdToNodeId(createNodeLocatorId(null, toNodeId(9)))
).toBe(toNodeId(9))
})
it('round-trips a root node id through locator translation', () => {
const store = useWorkflowStore()
const locator = store.nodeIdToNodeLocatorId(toNodeId(42))
expect(store.nodeLocatorIdToNodeId(locator)).toBe(toNodeId(42))
})
it('maps a root locator to a single-segment execution id', () => {
const store = useWorkflowStore()
expect(
store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId(null, toNodeId(5))
)
).toBe('5')
})
})

View File

@@ -1,100 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: () => {},
getUserData: async () => ({ status: 404 }),
storeUserData: async () => {}
}
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
moveWorkflowThumbnail: () => {},
clearThumbnail: () => {}
})
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStoreV2', () => ({
useWorkflowDraftStoreV2: () => ({
getDraft: () => null,
saveDraft: () => {},
deleteDraft: () => {}
})
}))
function wf(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
beforeEach(() => {
setActivePinia(createPinia())
})
describe('workflowStore tab management', () => {
it('attaches workflows into the lookup and finds them by path', () => {
const store = useWorkflowStore()
const a = wf('a.json')
store.attachWorkflow(a)
// Pinia wraps stored objects in reactive proxies, so compare structurally.
expect(store.getWorkflowByPath('a.json')).toEqual(a)
expect(store.getWorkflowByPath('missing.json')).toBeNull()
expect(store.workflows).toContainEqual(a)
})
it('tracks which workflows are open', () => {
const store = useWorkflowStore()
const open = wf('open.json')
const closed = wf('closed.json')
store.attachWorkflow(open, 0)
store.attachWorkflow(closed)
expect(store.isOpen(open)).toBe(true)
expect(store.isOpen(closed)).toBe(false)
expect(store.openWorkflows).toEqual([open])
})
it('reorders open workflow tabs', () => {
const store = useWorkflowStore()
const a = wf('a.json')
const b = wf('b.json')
const c = wf('c.json')
store.attachWorkflow(a, 0)
store.attachWorkflow(b, 1)
store.attachWorkflow(c, 2)
store.reorderWorkflows(0, 2)
expect(store.openWorkflows).toEqual([b, c, a])
})
it('opens background workflows on the requested side, ignoring unknown paths', () => {
const store = useWorkflowStore()
const left = wf('left.json')
const mid = wf('mid.json')
const right = wf('right.json')
store.attachWorkflow(left)
store.attachWorkflow(mid, 0)
store.attachWorkflow(right)
store.openWorkflowsInBackground({
left: ['left.json', 'unknown.json'],
right: ['right.json']
})
expect(store.openWorkflows).toEqual([left, mid, right])
expect(store.activeWorkflow).toBeNull()
})
it('reports no active workflow before one is opened', () => {
const store = useWorkflowStore()
expect(store.isActive(wf('a.json'))).toBe(false)
})
})

View File

@@ -1,247 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
const { coreByLocale, coreResult, customResult, dist, locale } = vi.hoisted(
() => ({
coreByLocale: { value: {} as Record<string, unknown[]> },
coreResult: { value: [] as unknown[] },
customResult: { value: {} as Record<string, string[]> },
dist: { isCloud: false },
locale: { value: 'en' }
})
)
const baseTemplate = {
name: 'default',
title: 'Default',
description: 'A basic template',
mediaType: 'image',
mediaSubtype: 'webp'
}
vi.mock('@/scripts/api', () => ({
api: {
getWorkflowTemplates: async () => customResult.value,
getCoreWorkflowTemplates: async (locale: string) =>
coreByLocale.value[locale] ?? coreResult.value,
fileURL: (p: string) => p
}
}))
vi.mock('@/i18n', () => ({
i18n: { global: { locale } },
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
}
}))
function coreCategory(
overrides: Partial<WorkflowTemplates> = {}
): WorkflowTemplates {
return {
moduleName: 'default',
title: 'Basics',
type: 'image',
templates: [baseTemplate],
...overrides
}
}
function navItems(items: (NavItemData | NavGroupData)[]) {
return items.flatMap((item) => ('items' in item ? item.items : [item]))
}
beforeEach(() => {
setActivePinia(createPinia())
coreByLocale.value = {}
coreResult.value = [coreCategory()]
customResult.value = {}
dist.isCloud = false
locale.value = 'en'
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response('', { headers: { 'content-type': 'text/html' } })
)
)
})
describe('workflowTemplatesStore', () => {
it('loads core templates and indexes their names', async () => {
const store = useWorkflowTemplatesStore()
expect(store.isLoaded).toBe(false)
await store.loadWorkflowTemplates()
expect(store.isLoaded).toBe(true)
expect(store.knownTemplateNames.has('default')).toBe(true)
expect(store.getTemplateByName('default')?.name).toBe('default')
expect(store.getTemplateByName('missing')).toBeUndefined()
})
it('exposes grouped templates with localized titles', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
expect(store.groupedTemplates.length).toBeGreaterThan(0)
const exampleGroup = store.groupedTemplates[0]
expect(exampleGroup.label).toBe('ComfyUI Examples')
const moduleTitles = exampleGroup.modules.map((m) => m.localizedTitle)
expect(moduleTitles).toContain('All Templates')
expect(moduleTitles).toContain('Basics')
const allNames = store.groupedTemplates.flatMap((g) =>
(g.modules ?? []).flatMap((m) => (m.templates ?? []).map((t) => t.name))
)
expect(allNames).toContain('default')
})
it('filters nav categories from loaded template metadata', async () => {
coreResult.value = [
coreCategory({
title: 'Getting Started',
isEssential: true,
templates: [{ ...baseTemplate, name: 'starter', title: 'Starter' }]
}),
coreCategory({
title: 'Image Tools',
category: 'GENERATION TYPE',
templates: [
{
...baseTemplate,
name: 'partner-upscale',
title: 'Partner Upscale',
openSource: false
},
{
...baseTemplate,
name: 'local-only',
requiresCustomNodes: ['custom-node']
}
]
})
]
customResult.value = { CustomPack: ['custom-flow'] }
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const allItems = navItems(store.navGroupedTemplates)
const basicsId = allItems.find(
(item) => item.label === 'Getting Started'
)?.id
const categoryId = allItems.find((item) => item.label === 'Image Tools')?.id
expect(store.filterTemplatesByCategory('all').map((t) => t.name)).toEqual([
'starter',
'partner-upscale',
'custom-flow'
])
expect(
store.filterTemplatesByCategory('popular').map((t) => t.name)
).toEqual(['starter', 'partner-upscale', 'custom-flow'])
expect(
store.filterTemplatesByCategory(basicsId ?? '').map((t) => t.name)
).toEqual(['starter'])
expect(
store.filterTemplatesByCategory(categoryId ?? '').map((t) => t.name)
).toEqual(['partner-upscale'])
expect(
store.filterTemplatesByCategory('partner-nodes').map((t) => t.name)
).toEqual(['partner-upscale'])
expect(
store.filterTemplatesByCategory('extension-CustomPack').map((t) => t.name)
).toEqual(['custom-flow'])
expect(
store.filterTemplatesByCategory('unknown').map((t) => t.name)
).toEqual(['starter', 'partner-upscale', 'custom-flow'])
})
it('loads logo indexes and rejects unsafe logo paths', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(
JSON.stringify({
valid: 'logos/valid.svg',
missingExtension: 'logos/valid',
parent: '../secret.svg',
rooted: '/logos/rooted.svg'
}),
{ headers: { 'content-type': 'application/json' } }
)
)
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
expect(store.getLogoUrl('valid')).toBe('/templates/logos/valid.svg')
expect(store.getLogoUrl('missing')).toBe('')
expect(store.getLogoUrl('missingExtension')).toBe('')
expect(store.getLogoUrl('parent')).toBe('')
expect(store.getLogoUrl('rooted')).toBe('')
})
it('returns english metadata when cloud loads a non-english locale', async () => {
dist.isCloud = true
locale.value = 'fr'
coreByLocale.value = {
fr: [
coreCategory({
templates: [{ ...baseTemplate, name: 'localized', title: 'Localise' }]
})
],
en: [
coreCategory({
title: 'English Category',
templates: [
{
...baseTemplate,
name: 'localized',
tags: ['tag'],
useCase: 'test',
models: ['model'],
license: 'MIT'
}
]
})
]
}
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
expect(store.getEnglishMetadata('localized')).toEqual({
tags: ['tag'],
category: 'English Category',
useCase: 'test',
models: ['model'],
license: 'MIT'
})
expect(store.getEnglishMetadata('missing')).toBeNull()
})
it('does not refetch once loaded', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
coreResult.value = []
await store.loadWorkflowTemplates()
expect(store.knownTemplateNames.has('default')).toBe(true)
})
it('returns null english metadata when no english templates are loaded', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
expect(store.getEnglishMetadata('default')).toBeNull()
})
})

View File

@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
emit('close')
}
@@ -336,13 +336,12 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
dialogService.showTopUpCreditsDialog()
emit('close')
}

View File

@@ -391,12 +391,13 @@ const showZeroState = computed(
)
function handleSubscribeWorkspace() {
showSubscriptionDialog()
showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleUpgrade() {
if (isFreeTierPlan.value) showPricingTable()
else showSubscriptionDialog()
if (isFreeTierPlan.value)
showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
function handleViewMoreDetails() {

View File

@@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
@@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue'
const { onClose, reason, initialPlanMode } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
initialPlanMode?: 'personal' | 'team'
}>()
@@ -152,7 +152,7 @@ const {
handleConfirmTransition,
handleTeamSubscribe,
handleResubscribe
} = useSubscriptionCheckout(emit)
} = useSubscriptionCheckout(emit, reason)
// Backspace mirrors the back arrow on the confirm step, but never while an
// editable element is focused (let it delete text there).

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
@@ -17,25 +17,10 @@ const mockHandleResubscribe = vi.fn()
const mockHandleSuccessClose = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
const mockPreviewData = ref<{ transition_type: string } | null>(null)
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
useSubscriptionCheckout: () => ({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
useSubscriptionCheckout: mockUseSubscriptionCheckout
}))
const i18n = createI18n({
@@ -91,7 +76,7 @@ const SuccessStub = {
function renderComponent(
props: {
onClose?: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
isPersonal?: boolean
} = {}
) {
@@ -121,6 +106,23 @@ function renderComponent(
describe('SubscriptionRequiredDialogContentWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSubscriptionCheckout.mockReturnValue({
checkoutStep: mockCheckoutStep,
isLoadingPreview: ref(false),
loadingTier: ref(null),
isSubscribing: ref(false),
isResubscribing: ref(false),
previewData: mockPreviewData,
selectedTierKey: ref('standard'),
selectedBillingCycle: ref('yearly'),
isPolling: ref(false),
handleSubscribeClick: mockHandleSubscribeClick,
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
mockCheckoutStep.value = 'pricing'
mockPreviewData.value = null
})
@@ -132,6 +134,15 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
})
it('passes the reason into subscription checkout', () => {
renderComponent({ reason: 'out_of_credits' })
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
expect.any(Function),
'out_of_credits'
)
})
it('shows the team workspace header by default', () => {
renderComponent()
expect(screen.getByText('Team Workspace')).toBeInTheDocument()

View File

@@ -116,7 +116,7 @@
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import PricingTableWorkspace from './PricingTableWorkspace.vue'
@@ -130,7 +130,7 @@ const {
isPersonal = false
} = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
reason?: PaymentIntentSource
isPersonal?: boolean
}>()
@@ -154,7 +154,7 @@ const {
handleConfirmTransition,
handleResubscribe,
handleSuccessClose
} = useSubscriptionCheckout(emit)
} = useSubscriptionCheckout(emit, reason)
</script>
<style scoped>

View File

@@ -61,6 +61,9 @@ function onDismiss() {
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
subscriptionDialog.show({ planMode: 'team' })
subscriptionDialog.show({
planMode: 'team',
reason: 'invite_member_upsell'
})
}
</script>

View File

@@ -277,7 +277,7 @@ export function useMembersPanel() {
}
function showTeamPlans() {
subscriptionDialog.show({ planMode: 'team' })
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
}
return {

View File

@@ -1,8 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { computed, reactive } from 'vue'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { findPlanSlug } from './useSubscriptionCheckout'
@@ -75,7 +76,9 @@ const {
mockPlans,
mockResubscribe,
mockToastAdd,
mockStartOperation
mockStartOperation,
mockTrackBeginCheckout,
mockUserId
} = vi.hoisted(() => ({
mockSubscribe: vi.fn(),
mockPreviewSubscribe: vi.fn(),
@@ -84,7 +87,9 @@ const {
mockPlans: { value: [] as Plan[] },
mockResubscribe: vi.fn(),
mockToastAdd: vi.fn(),
mockStartOperation: vi.fn()
mockStartOperation: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
@@ -119,7 +124,14 @@ vi.mock('primevue/usetoast', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
useTelemetry: () => ({
trackMonthlySubscriptionSucceeded: vi.fn(),
trackBeginCheckout: mockTrackBeginCheckout
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
vi.mock('vue-i18n', async (importOriginal) => {
@@ -135,10 +147,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
describe('useSubscriptionCheckout', () => {
let emit: ReturnType<typeof vi.fn>
async function setup() {
async function setup(paymentIntentSource?: PaymentIntentSource) {
const { useSubscriptionCheckout } =
await import('./useSubscriptionCheckout')
return useSubscriptionCheckout(emit as never)
return useSubscriptionCheckout(emit as never, paymentIntentSource)
}
beforeEach(() => {
@@ -146,6 +158,7 @@ describe('useSubscriptionCheckout', () => {
vi.clearAllMocks()
mockPlans.value = allPlans()
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
mockUserId.value = 'user-1'
emit = vi.fn()
})
@@ -459,6 +472,13 @@ describe('useSubscriptionCheckout', () => {
cancelUrl: 'https://platform.comfy.org/payment/failed'
})
expect(checkout.checkoutStep.value).toBe('success')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
checkout_type: 'new',
billing_op_id: 'op-team-1'
})
)
})
it('uses the annual plan slug for the yearly cycle', async () => {
@@ -553,6 +573,39 @@ describe('useSubscriptionCheckout', () => {
detail: 'Team payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('keeps team checkout_type as change when the preview request fails', async () => {
const checkout = await setup()
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
await checkout.handleSubscribeTeamClick({
stop: {
id: 'team_1400',
usd: 1400,
credits: 295_400,
discountedUsd: 1295
},
billingCycle: 'monthly',
isChange: true
})
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-team-change'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleTeamSubscribe()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'team',
cycle: 'monthly',
checkout_type: 'change',
billing_op_id: 'op-team-change'
})
)
})
})
@@ -603,6 +656,47 @@ describe('useSubscriptionCheckout', () => {
expect(checkout.checkoutStep.value).toBe('success')
})
it('skips begin_checkout when no user id is available', async () => {
mockUserId.value = null
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
mockUserId.value = 'user-1'
})
it('fires begin_checkout carrying the payment intent source', async () => {
const checkout = await setup('subscribe_to_run')
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
mockSubscribe.mockResolvedValueOnce({
status: 'subscribed',
billing_op_id: 'op-1'
})
mockFetchStatus.mockResolvedValueOnce(undefined)
mockFetchBalance.mockResolvedValueOnce(undefined)
await checkout.handleAddCreditCard()
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'standard',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op-1',
payment_intent_source: 'subscribe_to_run'
})
})
it('opens payment URL when needs_payment_method', async () => {
const checkout = await setup()
checkout.selectedTierKey.value = 'standard'
@@ -720,6 +814,7 @@ describe('useSubscriptionCheckout', () => {
detail: 'Payment failed'
})
)
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
})

View File

@@ -9,16 +9,26 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
type CheckoutStep = 'pricing' | 'preview' | 'success'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
interface SelectedTeamCheckout {
stop: TeamPlanSelection
checkoutType: SubscriptionCheckoutType
}
/**
* Which screen the `preview` step shows. Only a change prorates: a team change
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
@@ -45,9 +55,12 @@ export function findPlanSlug(
return plan?.slug ?? null
}
export function useSubscriptionCheckout(emit: {
(e: 'close', subscribed: boolean): void
}) {
export function useSubscriptionCheckout(
emit: {
(e: 'close', subscribed: boolean): void
},
paymentIntentSource?: PaymentIntentSource
) {
const { t } = useI18n()
const toast = useToast()
const {
@@ -68,13 +81,16 @@ export function useSubscriptionCheckout(emit: {
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
const selectedTeamStop = computed(
() => selectedTeamCheckout.value?.stop ?? null
)
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
const previewVariant = computed<PreviewVariant>(() => {
if (selectedTeamStop.value) {
if (selectedTeamCheckout.value) {
return previewData.value ? 'team-change' : 'team-new'
}
if (previewData.value) {
@@ -154,7 +170,10 @@ export function useSubscriptionCheckout(emit: {
billingCycle: BillingCycle
isChange?: boolean
}) {
selectedTeamStop.value = payload.stop
selectedTeamCheckout.value = {
stop: payload.stop,
checkoutType: payload.isChange ? 'change' : 'new'
}
selectedBillingCycle.value = payload.billingCycle
selectedTierKey.value = null
previewData.value = null
@@ -182,7 +201,7 @@ export function useSubscriptionCheckout(emit: {
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
selectedTeamStop.value = null
selectedTeamCheckout.value = null
}
function handleSuccessClose() {
@@ -190,20 +209,34 @@ export function useSubscriptionCheckout(emit: {
}
async function handleSubscription() {
if (!selectedTierKey.value) return
const tierKey = selectedTierKey.value
if (!tierKey) return
const billingCycle = selectedBillingCycle.value
const checkoutType =
previewData.value &&
previewData.value.transition_type !== 'new_subscription'
? 'change'
: 'new'
isSubscribing.value = true
try {
const planSlug = getApiPlanSlug(
selectedTierKey.value,
selectedBillingCycle.value
)
const planSlug = getApiPlanSlug(tierKey, billingCycle)
if (!planSlug) return
const response = await subscribe(planSlug, {
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: tierKey,
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)
@@ -269,8 +302,8 @@ export function useSubscriptionCheckout(emit: {
}
async function handleTeamSubscription() {
const stop = selectedTeamStop.value
if (!stop?.id) {
const teamCheckout = selectedTeamCheckout.value
if (!teamCheckout?.stop.id) {
toast.add({
severity: 'error',
summary: t('subscription.teamPlan.name'),
@@ -279,16 +312,28 @@ export function useSubscriptionCheckout(emit: {
return
}
const { stop, checkoutType } = teamCheckout
const billingCycle = selectedBillingCycle.value
isSubscribing.value = true
try {
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
const planSlug = getTeamPlanSlug(billingCycle)
const response = await subscribe(planSlug, {
teamCreditStopId: stop.id,
billingCycle: selectedBillingCycle.value,
billingCycle,
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
})
if (response) {
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType,
billingOpId: response.billing_op_id,
paymentIntentSource
})
}
await handleSubscribeResponse(response)
} catch (error) {
showSubscribeError(error)

View File

@@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
@@ -275,12 +276,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
subscriptionDialog.show({ reason: 'subscription_required' })
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
subscriptionDialog.show(options)
}
return {

View File

@@ -0,0 +1,38 @@
import { useTelemetry } from '@/platform/telemetry'
import type {
PaymentIntentSource,
SubscriptionCheckoutTier,
SubscriptionCheckoutType
} from '@/platform/telemetry/types'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useAuthStore } from '@/stores/authStore'
interface TrackWorkspaceCheckoutStartedOptions {
tier: SubscriptionCheckoutTier
cycle: BillingCycle
checkoutType: SubscriptionCheckoutType
billingOpId: string
paymentIntentSource?: PaymentIntentSource
}
export function trackWorkspaceCheckoutStarted({
tier,
cycle,
checkoutType,
billingOpId,
paymentIntentSource
}: TrackWorkspaceCheckoutStartedOptions) {
const { userId } = useAuthStore()
if (!userId) return
useTelemetry()?.trackBeginCheckout({
user_id: userId,
tier,
cycle,
checkout_type: checkoutType,
billing_op_id: billingOpId,
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {})
})
}

View File

@@ -0,0 +1,208 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -134,9 +148,10 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -166,18 +181,24 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -198,9 +219,14 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -18,7 +18,7 @@ import type {
} from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
// Lazy loaders for dialogs - components are loaded on first use
@@ -442,9 +442,9 @@ export const useDialogService = () => {
})
}
async function showSubscriptionRequiredDialog(options?: {
reason?: SubscriptionDialogReason
}) {
async function showSubscriptionRequiredDialog(
options?: SubscriptionDialogOptions
) {
if (!isCloud || !window.__CONFIG__?.subscription_required) {
return
}

View File

@@ -0,0 +1,142 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SystemStats } from '@/schemas/apiSchema'
import type { AboutPageBadge } from '@/types/comfy'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
type SystemInfo = Partial<SystemStats['system']>
const { dist, stats, exts } = vi.hoisted(() => ({
dist: { isCloud: false, isDesktop: false },
stats: { system: {} as SystemInfo },
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: {
github: 'https://github.com/comfyanonymous/ComfyUI',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
comfyOrg: 'https://comfy.org',
discord: 'https://discord.com'
}
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({ extensions: exts.list })
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => ({ systemStats: stats })
}))
function label(badges: AboutPageBadge[], includes: string) {
return badges.find((b) => b.label.includes(includes))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
dist.isCloud = false
dist.isDesktop = false
stats.system = {}
exts.list = []
})
describe('aboutPanelStore', () => {
it('builds the default desktop-less, non-cloud core badges', () => {
stats.system = { comfyui_version: 'abc1234' }
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-github')
expect(core.url).toContain('github.com/comfyanonymous')
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
expect(label(store.badges, 'Discord')).toBeDefined()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('uses cloud url and icon for the core badge when running on cloud', () => {
dist.isCloud = true
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-cloud')
expect(core.url).toBe('https://comfy.org')
})
it('uses the electron-reported version label on desktop', () => {
dist.isDesktop = true
const store = useAboutPanelStore()
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
})
it('adds a danger templates badge when the installed version is outdated', () => {
stats.system = {
installed_templates_version: '1.0.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.0.0')!
expect(templates.severity).toBe('danger')
})
it('adds a templates badge without severity when versions match', () => {
stats.system = {
installed_templates_version: '1.1.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('does not mark templates outdated when the required version is missing', () => {
stats.system = {
installed_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('omits the templates badge when the installed version is missing', () => {
stats.system = {
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('appends extension badges and tolerates extensions without any', () => {
exts.list = [
{
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
},
{}
]
const store = useAboutPanelStore()
expect(label(store.badges, 'My Ext')).toBeDefined()
})
})

View File

@@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
const authStoreMock = vi.hoisted(() => ({
createCustomer: vi.fn()
}))
const toastStoreMock = vi.hoisted(() => ({
add: vi.fn()
}))
const errorHandlingMock = vi.hoisted(() => ({
toastErrorHandler: vi.fn(),
forceGenericFailure: false,
forceStorageFailure: false
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreMock
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => toastStoreMock
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: errorHandlingMock.toastErrorHandler,
wrapWithErrorHandlingAsync:
(
fn: (value?: string) => Promise<boolean>,
onError: (e: unknown) => void
) =>
async (value?: string) => {
try {
if (errorHandlingMock.forceStorageFailure) {
throw new Error('STORAGE_FAILED')
}
if (errorHandlingMock.forceGenericFailure) {
throw new Error('OTHER_FAILED')
}
return await fn(value)
} catch (e) {
onError(e)
return false
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
describe('apiKeyAuthStore', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
authStoreMock.createCustomer.mockReset()
toastStoreMock.add.mockClear()
errorHandlingMock.toastErrorHandler.mockClear()
errorHandlingMock.forceGenericFailure = false
errorHandlingMock.forceStorageFailure = false
})
it('stores an API key, initializes the user, and returns an auth header', async () => {
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(true)
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
expect(store.isAuthenticated).toBe(true)
expect(store.getApiKey()).toBe('secret')
expect(store.getAuthHeader()).toEqual({ 'X-API-KEY': 'secret' })
expect(toastStoreMock.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('clears the API key when user initialization cannot create a customer', async () => {
authStoreMock.createCustomer.mockResolvedValue(undefined)
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(true)
await vi.waitFor(() => expect(store.getApiKey()).toBeNull())
expect(store.currentUser).toBeNull()
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
expect.objectContaining({ message: 'auth.login.noAssociatedUser' })
)
})
it('clears the user when the API key is cleared', async () => {
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
const store = useApiKeyAuthStore()
await store.storeApiKey('secret')
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
await expect(store.clearStoredApiKey()).resolves.toBe(true)
expect(store.currentUser).toBeNull()
expect(store.isAuthenticated).toBe(false)
expect(store.getAuthHeader()).toBeNull()
})
it('reports storage failures through the API-key toast copy', async () => {
errorHandlingMock.forceStorageFailure = true
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(false)
expect(toastStoreMock.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'auth.apiKey.storageFailed',
detail: 'auth.apiKey.storageFailedDetail'
})
expect(errorHandlingMock.toastErrorHandler).not.toHaveBeenCalled()
})
it('reports non-storage failures through the generic toast handler', async () => {
errorHandlingMock.forceGenericFailure = true
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(false)
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
expect.any(Error)
)
})
})

View File

@@ -37,20 +37,6 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
currentUser.value = createCustomerResponse
}
watch(
apiKey,
() => {
if (apiKey.value) {
// IF API key is set, initialize user
void initializeUserFromApiKey()
} else {
// IF API key is cleared, clear user
currentUser.value = null
}
},
{ immediate: true }
)
const reportError = (error: unknown) => {
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
toastStore.add({
@@ -63,6 +49,18 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
}
}
watch(
apiKey,
() => {
if (apiKey.value) {
initializeUserFromApiKey().catch(reportError)
} else {
currentUser.value = null
}
},
{ immediate: true }
)
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
apiKey.value = newApiKey
toastStore.add({

View File

@@ -0,0 +1,190 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const {
handlers,
openSet,
errorStore,
dist,
resolvePrecondition,
classifyCloud
} = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>(),
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
dist: { isCloud: false },
resolvePrecondition: vi.fn(),
classifyCloud: vi.fn()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => errorStore
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
}
}))
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
resolveAccountPrecondition: resolvePrecondition
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function workflow(path: string): ComfyWorkflow {
return fromPartial<ComfyWorkflow>({ path })
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function fireError(detail: Record<string, unknown>) {
handlers['execution_error']?.({ detail })
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
dist.isCloud = false
resolvePrecondition.mockReturnValue(null)
classifyCloud.mockReturnValue(null)
for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors'])
delete errorStore[key]
})
describe('executionStore error handling', () => {
it('marks an open workflow failed and records the raw execution error', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-1',
promptOutput: promptOutput(),
workflow: wf
})
const detail = {
prompt_id: 'job-1',
node_id: '5',
exception_message: 'boom'
}
fireError(detail)
expect(store.getWorkflowStatus(wf)).toBe('failed')
expect(errorStore.lastExecutionError).toBe(detail)
})
it('routes account-precondition errors away from the failed badge', () => {
resolvePrecondition.mockReturnValue({ type: 'credits' })
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
fireError({ prompt_id: 'job-2', exception_type: 'AccountError' })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastExecutionError).toBeUndefined()
})
it('records a node-less service-level error as a prompt error', () => {
setup()
fireError({
prompt_id: 'job-3',
exception_type: 'StagnationError',
exception_message: 'stuck',
traceback: ['line1', 'line2']
})
expect(errorStore.lastPromptError).toEqual({
type: 'StagnationError',
message: 'StagnationError: stuck',
details: 'line1\nline2'
})
})
it('records classified cloud validation node errors without a failed badge', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'nodeErrors',
nodeErrors: { '5': { errors: [] } }
})
const store = setup()
const wf = workflow('c.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-4',
promptOutput: promptOutput(),
workflow: wf
})
fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } })
})
})

View File

@@ -0,0 +1,231 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
setSpy: vi.fn(),
nodeDefs: {} as Record<string, unknown>
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { reactive } = await import('vue')
const reactiveSettings = reactive(settings)
setSpy.mockImplementation(async (id: string, value: unknown) => {
reactiveSettings[id] = value
})
return {
useSettingStore: () => ({
get: (id: string) => reactiveSettings[id],
set: setSpy
})
}
})
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
createDummyFolderNodeDef: (path: string) => ({
isDummyFolder: true,
nodePath: path,
name: path
})
}))
function folderNode(nodePath: string): ComfyNodeDefImpl {
return fromPartial<ComfyNodeDefImpl>({
isDummyFolder: true,
nodePath,
category: nodePath.replace(/\/$/, ''),
name: nodePath
})
}
function leafNode(name: string, nodePath = name): ComfyNodeDefImpl {
return fromPartial<ComfyNodeDefImpl>({
isDummyFolder: false,
name,
nodePath,
category: ''
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
for (const key of Object.keys(settings)) delete settings[key]
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
settings[BOOKMARK_ID] = []
settings[CUSTOMIZATION_ID] = {}
setSpy.mockClear()
})
describe('nodeBookmarkStore', () => {
it('reports isBookmarked by either nodePath or top-level name', () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
const store = useNodeBookmarkStore()
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
true
)
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
})
it('adds a bookmark by appending to the current list', async () => {
settings[BOOKMARK_ID] = ['A']
const store = useNodeBookmarkStore()
await store.addBookmark('B')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
})
it('toggles an un-bookmarked node by adding its name', async () => {
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
})
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
expect(store.bookmarks).toEqual([])
})
it('creates a folder under a parent and at the root', async () => {
const store = useNodeBookmarkStore()
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
expect(rootPath).toBe('Favorites/')
const childPath = await store.addNewBookmarkFolder(
folderNode('Favorites/'),
'Nested'
)
expect(childPath).toBe('Favorites/Nested/')
})
it('builds the bookmark tree, dropping unknown node defs', () => {
nodeDefs['KSampler'] = leafNode('KSampler')
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
const store = useNodeBookmarkStore()
const children = (store.bookmarkedRoot as { children: unknown[] }).children
expect(children).toHaveLength(2)
})
describe('renameBookmarkFolder', () => {
it('rejects renaming a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
).rejects.toThrow('Cannot rename non-folder node')
})
it('rejects a name containing a slash', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
).rejects.toThrow('cannot contain')
})
it('rejects a rename that collides with an existing folder', async () => {
settings[BOOKMARK_ID] = ['Taken/']
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
).rejects.toThrow('already exists')
})
it('rewrites matching bookmark paths on a valid rename', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
'New/',
'New/KSampler',
'Other/Node'
])
})
it('does nothing when the folder keeps the same path', async () => {
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
expect(setSpy).not.toHaveBeenCalled()
})
})
it('deletes a folder and all its descendants', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
const store = useNodeBookmarkStore()
await store.deleteBookmarkFolder(folderNode('Old/'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
})
it('rejects deleting a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.deleteBookmarkFolder(leafNode('KSampler'))
).rejects.toThrow('Cannot delete non-folder node')
})
describe('updateBookmarkCustomization', () => {
it('persists a non-default customization', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: '#ff0000',
icon: 'pi-star'
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': { color: '#ff0000', icon: 'pi-star' }
})
})
it('drops attributes set to their default values', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: store.defaultBookmarkColor,
icon: store.defaultBookmarkIcon
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': undefined
})
})
})
it('renames a customization entry, moving the old key to the new one', async () => {
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
const store = useNodeBookmarkStore()
await store.renameBookmarkCustomization('Old/', 'New/')
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'New/': { color: '#abc' }
})
})
})

View File

@@ -10,6 +10,8 @@ import type {
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyApp } from '@/scripts/app'
import * as jobOutputCache from '@/services/jobOutputCache'
import type { TaskOutput } from '@/schemas/apiSchema'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/services/extensionService', () => ({
@@ -44,7 +46,9 @@ const mockJobDetail = {
}
},
outputs: {
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
'1': {
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
}
}
}
@@ -137,4 +141,98 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should load full outputs for history tasks', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
mockJobDetail as JobDetail
)
const loaded = await task.loadFullOutputs()
expect(loaded).not.toBe(task)
expect(loaded.flatOutputs[0].filename).toBe('test.png')
})
it('should not load full outputs for running tasks', async () => {
const job = createRunningJob('test-job-id')
const task = new TaskItemImpl(job)
const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail')
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
expect(detailSpy).not.toHaveBeenCalled()
})
it('should keep history tasks when full outputs are unavailable', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ id: 'test-job-id', status: 'completed' })
)
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
})
it('should load workflow outputs from the task when job detail has none', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, mockJobDetail.outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).toHaveBeenCalledOnce()
})
it('should skip workflow output loading when no outputs exist', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, undefined)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).not.toHaveBeenCalled()
})
it('should skip invalid node execution ids while loading outputs', async () => {
const job = createHistoryJob('test-job-id')
const outputs = fromPartial<TaskOutput>({
'': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] },
'1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] }
})
const task = new TaskItemImpl(job, outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(setOutputsSpy).toHaveBeenCalledOnce()
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
})
})

View File

@@ -3,10 +3,17 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import type { StatusWsMessageStatus, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import {
isInstantMode,
isInstantRunningMode,
ResultItemImpl,
TaskItemImpl,
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
// Fixture factory for JobListItem
function createJob(
@@ -67,6 +74,72 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should use the raw URL as preview URL for non-images', () => {
const output = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'video',
filename: 'clip.webm',
type: 'output',
subfolder: ''
})
expect(output.previewUrl).toBe(output.url)
})
it('should recognize VHS mp4 and unsupported video formats', () => {
const webm = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/webm',
frame_rate: 24
})
const mp4 = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/mp4',
frame_rate: 24
})
const avi = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/avi',
frame_rate: 24
})
expect(webm.htmlVideoType).toBe('video/webm')
expect(mp4.htmlVideoType).toBe('video/mp4')
expect(avi.htmlVideoType).toBeUndefined()
})
it('should detect image media type without an image suffix', () => {
const image = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated',
type: 'output',
subfolder: ''
})
const audioFile = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated.wav',
type: 'output',
subfolder: ''
})
expect(image.isImage).toBe(true)
expect(audioFile.isImage).toBe(false)
})
it('should exclude animated from flatOutputs', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
@@ -259,6 +332,38 @@ describe('TaskItemImpl', () => {
expect(taskItem.executionError).toEqual(errorDetail)
})
})
it('should expose queue API task type for running tasks', () => {
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
expect(task.apiTaskType).toBe('queue')
})
it('should return empty flat outputs when outputs are missing', () => {
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'), undefined)
expect(task.calculateFlatOutputs()).toEqual([])
})
it('should calculate execution time in seconds', () => {
const task = new TaskItemImpl({
...createHistoryJob(0, 'job-id'),
execution_start_time: 1000,
execution_end_time: 3500
})
expect(task.executionStartTimestamp).toBe(1000)
expect(task.executionEndTimestamp).toBe(3500)
expect(task.executionTime).toBe(2500)
expect(task.executionTimeInSeconds).toBe(2.5)
})
it('should return undefined execution seconds without both timestamps', () => {
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'))
expect(task.executionTime).toBeUndefined()
expect(task.executionTimeInSeconds).toBeUndefined()
})
})
describe('useQueueStore', () => {
@@ -314,6 +419,23 @@ describe('useQueueStore', () => {
expect(store.pendingTasks[1].jobId).toBe('pend-1')
})
it('should register workflow ids for active jobs', async () => {
const executionStore = useExecutionStore()
const registerSpy = vi.spyOn(
executionStore,
'registerJobWorkflowIdMapping'
)
mockGetQueue.mockResolvedValue({
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
Pending: []
})
mockGetHistory.mockResolvedValue([])
await store.update()
expect(registerSpy).toHaveBeenCalledWith('run-1', 'wf-1')
})
it('should load history tasks from API', async () => {
const historyJob1 = createHistoryJob(5, 'hist-1')
const historyJob2 = createHistoryJob(4, 'hist-2')
@@ -1115,3 +1237,43 @@ describe('useQueueStore', () => {
})
})
})
describe('useQueuePendingTaskCountStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates from status websocket messages', () => {
const store = useQueuePendingTaskCountStore()
store.update(
new CustomEvent<StatusWsMessageStatus>('status', {
detail: { exec_info: { queue_remaining: 3 } }
})
)
expect(store.count).toBe(3)
})
it('falls back to zero when status details are missing', () => {
const store = useQueuePendingTaskCountStore()
store.count = 3
store.update(new CustomEvent<StatusWsMessageStatus>('status'))
expect(store.count).toBe(0)
})
})
describe('queue mode helpers', () => {
it('detect instant queue modes', () => {
expect(isInstantMode('instant-idle')).toBe(true)
expect(isInstantMode('instant-running')).toBe(true)
expect(isInstantMode('change')).toBe(false)
})
it('detect instant running mode', () => {
expect(isInstantRunningMode('instant-running')).toBe(true)
expect(isInstantRunningMode('instant-idle')).toBe(false)
})
})

View File

@@ -3,7 +3,6 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import type * as VueRouter from 'vue-router'
@@ -103,24 +102,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
function makeSubgraph(id: string): Subgraph {
return fromPartial<Subgraph>({
id,
isRootGraph: false,
rootGraph: app.rootGraph,
_nodes: [],
nodes: []
})
}
async function makeDuplicatedNavigationFailure(): Promise<Error> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: {} }]
})
await router.push('/')
const failure = await router.push('/')
if (!failure) throw new Error('Expected duplicated navigation failure')
return failure
}
async function flushHashWatcher() {
await nextTick()
await Promise.resolve()
@@ -131,7 +118,6 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(app.canvas.setGraph).mockReset()
app.rootGraph.id = ids.root
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
@@ -244,42 +230,6 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
warnSpy.mockRestore()
})
it('does not warn when recovery redirect hits a duplicated navigation', async () => {
routerMocks.replace.mockRejectedValueOnce(
await makeDuplicatedNavigationFailure()
)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
expect(warnSpy).not.toHaveBeenCalledWith(
'[subgraphNavigation] router.replace rejected during recovery',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('recovers to root when canvas is unavailable during redirect cleanup', async () => {
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
const canvas = appWithOptionalCanvas.canvas
appWithOptionalCanvas.canvas = undefined
useSubgraphNavigationStore()
routeHashRef.value = '#not-a-valid-uuid'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
appWithOptionalCanvas.canvas = canvas
})
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowStoreState.openWorkflows = [
@@ -354,196 +304,4 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
warnSpy.mockRestore()
})
it('updateHash does nothing on initial load with an empty hash', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(routerMocks.push).not.toHaveBeenCalled()
})
it('updateHash follows a non-empty initial subgraph hash', async () => {
const subgraph = makeSubgraph(ids.validSubgraph)
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
vi.mocked(app.canvas.setGraph).mockImplementation((graph) => {
app.canvas.graph = graph
})
routeHashRef.value = `#${ids.validSubgraph}`
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
})
it('updateHash does not treat the initial root hash as a subgraph', async () => {
routeHashRef.value = `#${ids.root}`
app.canvas.graph = app.rootGraph
const store = useSubgraphNavigationStore()
await store.updateHash()
expect(workflowStoreState.activeSubgraph).toBeUndefined()
})
it('updateHash replaces an empty hash and pushes the active graph id', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
await store.updateHash()
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(routerMocks.push).toHaveBeenCalledWith(`#${ids.validSubgraph}`)
})
it('updateHash skips router push when hash already matches the active graph', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
routeHashRef.value = `#${ids.validSubgraph}`
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
await store.updateHash()
expect(routerMocks.push).not.toHaveBeenCalled()
})
it('updateHash skips router push when the active graph has no id', async () => {
const store = useSubgraphNavigationStore()
await store.updateHash()
routeHashRef.value = '#old'
app.canvas.graph = fromPartial<LGraph>({})
await store.updateHash()
expect(routerMocks.push).not.toHaveBeenCalled()
})
it('updateHash warns when router push rejects', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
routerMocks.push.mockRejectedValueOnce(new Error('push failed'))
const store = useSubgraphNavigationStore()
await store.updateHash()
routeHashRef.value = '#old'
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
await store.updateHash()
expect(warnSpy).toHaveBeenCalledWith(
'[subgraphNavigation] router.push rejected',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('updateHash ignores duplicated router push failures', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
routerMocks.push.mockRejectedValueOnce(
await makeDuplicatedNavigationFailure()
)
const store = useSubgraphNavigationStore()
await store.updateHash()
routeHashRef.value = `#${ids.root}`
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
await store.updateHash()
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('skips workflows without active state during hash recovery', async () => {
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({ path: 'inactive.json' })
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('skips workflow states and subgraphs that do not match the hash', async () => {
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'other-workflow.json',
activeState: {
id: ids.validSubgraph,
definitions: {
subgraphs: [{ id: ids.validSubgraph }]
}
}
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('handles workflow states with no subgraph definitions during recovery', async () => {
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'no-definitions.json',
activeState: { id: ids.validSubgraph }
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('opens a workflow and navigates to the loaded root graph', async () => {
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'root-workflow.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
app.rootGraph.id = ids.deletedSubgraph
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
})
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
)
})
it('does not reset the graph when loaded workflow is already active', async () => {
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'already-active.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
app.rootGraph.id = ids.deletedSubgraph
app.canvas.graph = fromPartial<LGraph>({ id: ids.deletedSubgraph })
})
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
)
expect(app.canvas.setGraph).not.toHaveBeenCalledWith(app.rootGraph)
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -137,20 +136,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('saveViewport', () => {
it('does not save when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
store.saveViewport('root')
expect(store.viewportCache.has(':root')).toBe(false)
appWithOptionalCanvas.canvas = canvas
})
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
mockCanvas.ds.state.scale = 2
@@ -179,36 +164,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
})
describe('restoreViewport', () => {
it('does nothing when canvas is unavailable', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
appWithOptionalCanvas.canvas = undefined
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(0)
appWithOptionalCanvas.canvas = canvas
})
it('does not apply cached viewport when canvas disappears', () => {
const store = useSubgraphNavigationStore()
const canvas = app.canvas
const appWithOptionalCanvas = app as unknown as {
canvas: typeof app.canvas | undefined
}
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
appWithOptionalCanvas.canvas = undefined
store.restoreViewport('root')
expect(mockSetDirty).not.toHaveBeenCalled()
appWithOptionalCanvas.canvas = canvas
})
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
@@ -311,10 +266,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = fromPartial<Subgraph>({
id: 'different-graph',
isRootGraph: false
})
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
@@ -331,10 +283,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = fromPartial<Subgraph>({
id: 'different-graph',
isRootGraph: false
})
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[0](performance.now())
@@ -392,23 +341,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const subgraph = fromPartial<Subgraph>({
id: 'sub1',
isRootGraph: false,
rootGraph: app.rootGraph
})
store.saveCurrentViewport()
store.viewportCache.clear()
workflowStore.activeSubgraph = subgraph
await nextTick()
expect(store.viewportCache.has(':root')).toBe(false)
})
it('preserves pre-existing cache entries across workflow switches', async () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()

View File

@@ -10,14 +10,9 @@ import {
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { GlobalSubgraphData } from '@/scripts/api'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -41,7 +36,6 @@ vi.mock('@/scripts/api', () => ({
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
deleteUserData: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
@@ -104,12 +98,6 @@ describe('useSubgraphStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useSubgraphStore()
vi.clearAllMocks()
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => 'testname'),
confirm: vi.fn(() => true)
})
)
})
it('should allow publishing of a subgraph', async () => {
@@ -146,86 +134,6 @@ describe('useSubgraphStore', () => {
await store.publishSubgraph()
expect(api.storeUserData).toHaveBeenCalled()
})
it('rejects publishing when a single subgraph node is not selected', async () => {
vi.mocked(comfyApp.canvas).selectedItems = new Set()
await expect(store.publishSubgraph()).rejects.toThrow(
'Must have single SubgraphNode selected to publish'
)
})
it('rejects publishing when serialization produces multiple nodes', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize(), subgraphNode.serialize()],
subgraphs: []
}))
await expect(store.publishSubgraph()).rejects.toThrow(
'Must have single SubgraphNode selected to publish'
)
})
it('rejects publishing when the serialized node is not a subgraph node', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas).draw = vi.fn()
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [{ ...subgraphNode.serialize(), type: 'missing' }],
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
}))
await expect(store.publishSubgraph('invalid')).rejects.toThrow(
'Loaded subgraph blueprint does not contain valid subgraph'
)
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('does not publish when the name prompt is cancelled', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize()],
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
}))
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => null),
confirm: vi.fn(() => true)
})
)
await store.publishSubgraph()
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('does not overwrite an existing blueprint when confirmation is cancelled', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize()],
subgraphs: [fromAny<ExportedSubgraph, unknown>(subgraph.serialize())]
}))
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => 'test'),
confirm: vi.fn(() => false)
})
)
await mockFetch({ 'test.json': mockGraph })
await store.publishSubgraph('test')
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('should display published nodes in the node library', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(
@@ -240,30 +148,6 @@ describe('useSubgraphStore', () => {
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})
it('switches into the nested subgraph when editing opens a wrapper graph', async () => {
await mockFetch({ 'test.json': mockGraph })
const setGraph = vi.fn()
const nested = { id: 'nested' }
vi.mocked(comfyApp.canvas).graph = fromAny<
NonNullable<typeof comfyApp.canvas.graph>,
unknown
>({
nodes: [{ subgraph: nested }],
setGraph
})
vi.mocked(comfyApp.canvas).setGraph = setGraph
await store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
expect(setGraph).toHaveBeenCalledWith(nested)
})
it('throws when editing an unloaded blueprint', async () => {
await expect(
store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
).rejects.toThrow('not yet loaded')
})
it('should allow subgraphs to be added to graph', async () => {
//mock
await mockFetch({ 'test.json': mockGraph })
@@ -282,12 +166,6 @@ describe('useSubgraphStore', () => {
expect(second.nodes[0].id).not.toBe(-1)
expect(second.definitions!.subgraphs![0].id).toBe('123')
})
it('throws when getting an unloaded blueprint', () => {
expect(() => store.getBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')).toThrow(
'not yet loaded'
)
})
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
@@ -310,59 +188,6 @@ describe('useSubgraphStore', () => {
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
describe('deleteBlueprint', () => {
it('throws for unloaded blueprints', async () => {
await expect(
store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
).rejects.toThrow('not yet loaded')
})
it('does not delete global blueprints', async () => {
await mockFetch(
{},
{
global_bp: {
name: 'Global Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'global_bp')
expect(api.deleteUserData).not.toHaveBeenCalled()
expect(store.isGlobalBlueprint('global_bp')).toBe(true)
})
it('does not delete when confirmation is cancelled', async () => {
await mockFetch({ 'test.json': mockGraph })
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => 'testname'),
confirm: vi.fn(() => false)
})
)
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
expect(api.deleteUserData).not.toHaveBeenCalled()
expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(true)
})
it('deletes user blueprints after confirmation', async () => {
await mockFetch({ 'test.json': mockGraph })
vi.mocked(api.deleteUserData).mockResolvedValue({
status: 204
} as Response)
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
expect(api.deleteUserData).toHaveBeenCalledWith('subgraphs/test.json')
expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(false)
})
})
describe('isUserBlueprint', () => {
it('should return true for user blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
@@ -460,205 +285,6 @@ describe('useSubgraphStore', () => {
consoleSpy.mockRestore()
})
it('continues when global blueprint discovery rejects', async () => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([])
vi.mocked(api.getGlobalSubgraphs).mockRejectedValue(
new Error('global down')
)
await store.fetchSubgraphs()
expect(store.subgraphBlueprints).toEqual([])
})
it('reports compact detail when more than three blueprints fail', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const addToast = vi.spyOn(useToastStore(), 'add')
await mockFetch(
{},
{
a: { name: 'A', info: { node_pack: 'test' }, data: '' },
b: { name: 'B', info: { node_pack: 'test' }, data: '' },
c: { name: 'C', info: { node_pack: 'test' }, data: '' },
d: { name: 'D', info: { node_pack: 'test' }, data: '' }
}
)
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ detail: 'x4' })
)
})
it('ignores invalid user blueprint files during fetch', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch({
'invalid.json': {
nodes: [],
definitions: { subgraphs: [] }
}
})
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to load subgraph blueprint',
expect.any(Error)
)
expect(store.subgraphBlueprints).toHaveLength(0)
consoleSpy.mockRestore()
})
it('rejects loaded blueprints whose wrapper node does not reference a subgraph', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch({
'invalid-ref.json': {
nodes: [{ id: 1, type: 'missing' }],
definitions: { subgraphs: [{ id: 'present' }] }
}
})
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to load subgraph blueprint',
expect.any(Error)
)
expect(store.subgraphBlueprints).toHaveLength(0)
consoleSpy.mockRestore()
})
it('rejects loaded blueprints without subgraph definitions', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch({
'missing-definitions.json': {
nodes: [{ id: 1, type: 'missing' }]
}
})
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to load subgraph blueprint',
expect.any(Error)
)
expect(store.subgraphBlueprints).toHaveLength(0)
consoleSpy.mockRestore()
})
it('rejects saving a blueprint whose active state has no subgraph definitions', async () => {
await mockFetch({ 'test.json': mockGraph })
const blueprint = useWorkflowStore().getWorkflowByPath(
'subgraphs/test.json'
)
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
blueprint.changeTracker!.activeState = fromAny<ComfyWorkflowJSON, unknown>({
nodes: [{ id: 1, type: '123' }]
})
await expect(blueprint.save()).rejects.toThrow(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
})
it('marks non-blueprint root nodes when saving an invalid blueprint', async () => {
vi.mocked(comfyApp.canvas).draw = vi.fn()
await mockFetch({ 'test.json': mockGraph })
const blueprint = useWorkflowStore().getWorkflowByPath(
'subgraphs/test.json'
)
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
blueprint.changeTracker!.activeState = fromAny<ComfyWorkflowJSON, unknown>({
nodes: [
{ id: 1, type: '123' },
{ id: 2, type: 'OtherNode' }
],
definitions: { subgraphs: [{ id: '123' }] }
})
await expect(blueprint.save()).rejects.toThrow(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
expect(comfyApp.canvas.draw).toHaveBeenCalledWith(true, true)
})
it('does not save a loaded blueprint when first-save confirmation is cancelled', async () => {
const confirm = vi.fn(() => false)
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => 'testname'),
confirm
})
)
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
true
await mockFetch({ 'test.json': mockGraph })
const blueprint = useWorkflowStore().getWorkflowByPath(
'subgraphs/test.json'
)
if (!blueprint) throw new Error('Blueprint was not loaded')
const result = await blueprint.save()
expect(result).toBe(blueprint)
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
type: 'overwriteBlueprint',
itemList: ['test']
})
)
expect(api.storeUserData).not.toHaveBeenCalled()
})
it('saves a loaded blueprint after first-save confirmation', async () => {
const confirm = vi.fn(() => true)
vi.mocked(useDialogService).mockReturnValue(
fromPartial<ReturnType<typeof useDialogService>>({
prompt: vi.fn(() => 'testname'),
confirm
})
)
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
true
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'subgraphs/test.json',
modified: Date.now(),
size: 2
})
} as Response)
await mockFetch({ 'test.json': mockGraph })
const blueprint = useWorkflowStore().getWorkflowByPath(
'subgraphs/test.json'
)
if (!blueprint) throw new Error('Blueprint was not loaded')
await blueprint.save()
const [path, data, options] = vi.mocked(api.storeUserData).mock.calls[0]
if (typeof data !== 'string') throw new Error('Expected saved JSON')
expect(path).toBe('subgraphs/test.json')
expect(JSON.parse(data)).toMatchObject({
nodes: [{ type: '123', title: 'test' }],
definitions: { subgraphs: [{ id: '123', name: 'test' }] }
})
expect(options).toEqual({
overwrite: true,
throwOnError: true,
full_info: true
})
})
it('returns an already-loaded blueprint when loading without force', async () => {
await mockFetch({ 'test.json': mockGraph })
const blueprint = useWorkflowStore().getWorkflowByPath(
'subgraphs/test.json'
)
if (!blueprint) throw new Error('Blueprint was not loaded')
await blueprint.load()
expect(api.getUserData).toHaveBeenCalledTimes(1)
})
it('should handle global blueprint with rejected data promise gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch(
@@ -780,29 +406,6 @@ describe('useSubgraphStore', () => {
expect(nodeDef?.description).toBe('This is a test blueprint')
})
it('does not copy workflowRendererVersion into subgraph metadata on load', async () => {
await mockFetch({
'metadata-load.json': {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', extra: {} }]
},
extra: {
BlueprintDescription: 'Loaded description',
workflowRendererVersion: 'Vue'
}
}
})
const blueprint = store.getBlueprint(
BLUEPRINT_TYPE_PREFIX + 'metadata-load'
)
expect(blueprint.definitions!.subgraphs![0].extra).toEqual({
BlueprintDescription: 'Loaded description'
})
})
it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -812,8 +415,7 @@ describe('useSubgraphStore', () => {
// Set metadata on the subgraph's extra (as the commands do)
subgraph.extra = {
BlueprintDescription: 'Test description',
BlueprintSearchAliases: ['alias1', 'alias2'],
workflowRendererVersion: 'Vue'
BlueprintSearchAliases: ['alias1', 'alias2']
}
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
@@ -862,7 +464,6 @@ describe('useSubgraphStore', () => {
const subgraphExtra = definitions.subgraphs[0]?.extra
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
expect(subgraphExtra?.workflowRendererVersion).toBe('Vue')
})
})

View File

@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"