Compare commits

..

9 Commits

Author SHA1 Message Date
Matt Miller
dac9e5c25f test: cover no-version install branch; use function declarations
- getPackUpdateStatus/isNightlyVersion as function declarations (AGENTS.md 24)
- add getPackUpdateStatus test for an installed pack with an undefined version
- mirror the real store (undefined version) in the not-installed composable test
2026-07-13 21:35:01 -07:00
Matt Miller
e45e7e45ee test: cover usePackUpdateStatus composable
Adds behavioral coverage for the update-status composable (update
detection, nightly handling, and reactivity to source changes),
raising patch coverage for the extracted helper's call sites.
2026-07-02 13:08:08 -07:00
Matt Miller
c97298a4e8 fix: guard against non-semver latest version in pack update status
A malformed registry latest_version (e.g. a git hash) would reach
semver.compare unvalidated and throw TypeError: Invalid Version,
breaking every update-status computed and the outdated-pack filters.
Validate latestVersion is semver before comparing.
2026-07-02 12:51:32 -07:00
Matt Miller
ae328319d3 refactor: extract shared pack update-status helper
Consolidate the duplicated 'is installed pack outdated' semver logic that
was reimplemented across usePackUpdateStatus, useUpdateAvailableNodes, and
useManagerDisplayPacks (filterOutdated), plus the open-coded nightly check in
usePacksSelection, into a single getPackUpdateStatus helper (with a standalone
isNightlyVersion predicate) in manager utils. Behavior-preserving.
2026-07-02 12:38:45 -07:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00: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
104 changed files with 2545 additions and 1975 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

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

@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.

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

@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isSubscriptionEnabled } = useSubscription()
// Constants
@@ -260,9 +260,9 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
// On the consolidated (workspace) billing flow, show the workspace settings
// panel; otherwise show the legacy subscription/credits panel.
const settingsPanel = shouldUseWorkspaceBilling.value
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'

View File

@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, onMounted, ref } from 'vue'
import { defineComponent, nextTick, onMounted, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render, screen, waitFor } from '@testing-library/vue'
import type * as DistributionTypes from '@/platform/distribution/types'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
const mockTelemetry = vi.hoisted(() => ({
checkForCompletedTopup: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => mockTelemetry
}))
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<typeof DistributionTypes>()),
isCloud: true
const mockBillingRouting = vi.hoisted(() => ({
shouldUseWorkspaceBilling: false
}))
vi.mock('@/composables/billing/useBillingRouting', async () => {
const { ref } = await import('vue')
const shouldUseWorkspaceBilling = ref(false)
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
get: () => shouldUseWorkspaceBilling.value,
set: (value: boolean) => {
shouldUseWorkspaceBilling.value = value
}
})
return {
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
}
})
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
@@ -68,7 +77,10 @@ const i18n = createI18n({
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model'
model: 'Model',
loadEventsError: 'Failed to load activity. Please try again.',
loadEventsUnknownError:
'Something went wrong while loading activity. Please refresh and try again.'
}
}
}
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
template: '<UsageLogsTable ref="tableRef" />'
})
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockBillingRouting.shouldUseWorkspaceBilling = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
})
})
it('shows error message when service throws', async () => {
it('shows a localized fallback instead of a raw Error message', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
expect(
screen.getByText(
'Something went wrong while loading activity. Please refresh and try again.'
)
).toBeInTheDocument()
})
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
})
it('shows a localized fallback when the service reports no message', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = null
renderWithAutoRefresh()
await waitFor(() => {
expect(
screen.getByText('Failed to load activity. Please try again.')
).toBeInTheDocument()
})
})
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
mockBillingRouting.shouldUseWorkspaceBilling = true
await renderLoaded()
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
it('discards a stale legacy response when routing flips mid-fetch', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
resolveLegacy(
makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
await flushMicrotasks()
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
})
it('runs top-up completion telemetry for a superseded response', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
const legacyResponse = makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
resolveLegacy(legacyResponse)
await waitFor(() => {
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
legacyResponse.events
)
})
})
})
describe('EventType integration', () => {

View File

@@ -96,11 +96,11 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
@@ -109,14 +109,15 @@ import {
useCustomerEventsService
} from '@/services/customerEventsService'
const { t } = useI18n()
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const pagination = ref({
page: 1,
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
return map
})
// A billing-route flip can overlap two loads against different backends; only
// the latest may mutate state, so a superseded response is discarded.
let latestLoadToken = 0
const loadEvents = async () => {
const loadToken = ++latestLoadToken
loading.value = true
error.value = null
@@ -148,10 +154,17 @@ const loadEvents = async () => {
page: pagination.value.page,
limit: pagination.value.limit
}
const response = useBillingApi.value
const response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
// Completion telemetry must run even when a mid-checkout route flip
// supersedes this load, since legacy and workspace backends emit different
// top-up events and the winning fetch may not carry the completion yet.
useTelemetry()?.checkForCompletedTopup(response?.events)
if (loadToken !== latestLoadToken) return
if (response) {
if (response.events) {
events.value = response.events
@@ -165,24 +178,25 @@ const loadEvents = async () => {
pagination.value.limit = response.limit
}
if (response.total) {
if (response.total != null) {
pagination.value.total = response.total
}
if (response.totalPages) {
if (response.totalPages != null) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
console.error('Error loading events:', err)
} finally {
loading.value = false
if (loadToken === latestLoadToken) loading.value = false
}
}
@@ -198,6 +212,12 @@ const refresh = async () => {
await loadEvents()
}
watch(shouldUseWorkspaceBilling, () => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
})
defineExpose({
refresh
})

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

@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -26,6 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
}
})
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
}
}
})
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
it('selects workspace type for team regardless of consolidated billing', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
subscriptionPlan: null
})
})
it('never clobbers the list-derived store when a subscription is absent', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
isSubscribed: false,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {

View File

@@ -1,12 +1,12 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
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
@@ -17,10 +17,10 @@ import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useBillingRouting } from './useBillingRouting'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
@@ -34,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
* Unified billing context that selects the billing implementation by build/flag.
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -68,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const { type } = useBillingRouting()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
@@ -95,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
@@ -169,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
// Sync subscription info to workspace store for display in workspace switcher.
// Subscribed means active AND not cancelled, so the delete button enables
// after cancellation, even before the period ends. A null subscription means
// "not loaded yet" (adapters are discarded on every workspace/type switch);
// skip it so the transient reinit gap can't clobber the list-derived baseline
// (personal workspaces and subscribed teams already read subscribed there).
watch(
subscription,
(sub) => {
@@ -185,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
// init detect that it was superseded (its captured adapter is no longer the
// active one), so a stale response can't resolve into a ready state for the
// wrong workspace.
function resetBillingState() {
legacyBillingRef.value = null
workspaceBillingRef.value = null
isInitialized.value = false
isLoading.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
resetBillingState()
return
}
resetBillingState()
if (!newWorkspaceId) return
isInitialized.value = false
try {
await initialize()
} catch (err) {
@@ -215,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
async function initialize(): Promise<void> {
if (isInitialized.value) return
const adapter = activeContext.value
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
await adapter.initialize()
if (activeContext.value !== adapter) return
isInitialized.value = true
} catch (err) {
if (activeContext.value !== adapter) return
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
if (activeContext.value === adapter) isLoading.value = false
}
}
@@ -281,8 +281,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

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockActiveWorkspace.value
}
})
}))
const personal = { id: 'w-personal', type: 'personal' as const }
const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
})
it('uses legacy billing when team workspaces are disabled', () => {
mockFlags.teamWorkspacesEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('legacy')
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
})

View File

@@ -0,0 +1,36 @@
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
// An unloaded workspace has no type yet; stay legacy so bootstrap never
// eagerly routes to workspace billing.
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
return 'legacy'
}
return 'workspace'
})
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
return { type, shouldUseWorkspaceBilling }
}

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

@@ -6,6 +6,12 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -219,6 +225,86 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
})
})
describe('auth-gated flags on cloud', () => {
beforeEach(() => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {}
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,7 +1,9 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -46,6 +49,26 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
* Cloud build it is always false; during the auth window it falls back to the
* cached session value so anonymous bootstrap config cannot route the user to
* the wrong backend before authenticated config confirms the flag.
*/
function resolveAuthGatedFlag(
flagKey: string,
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
return resolveAuthGatedFlag(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
remoteConfig.value.team_workspaces_enabled,
cachedTeamWorkspacesEnabled
)
},
get userSecretsEnabled() {
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
false
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

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

@@ -2484,6 +2484,8 @@
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized",
"loadEventsError": "Failed to load activity. Please try again.",
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
"eventTypes": {
"creditAdded": "Credits Added",
"accountCreated": "Account Created",

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

@@ -18,7 +18,7 @@
</div>
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
<!-- Legacy mode: user-level subscription content -->
<template v-else>
<SubscriptionPanelContentLegacy />
@@ -29,24 +29,20 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isActiveSubscription } = useBillingContext()
</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,9 +5,11 @@ 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 mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
@@ -33,12 +35,10 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/billing/useBillingRouting', () => ({
useBillingRouting: () => ({
get shouldUseWorkspaceBilling() {
return mockShouldUseWorkspaceBilling
}
})
}))
@@ -60,10 +60,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,7 +85,8 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTeamWorkspacesEnabled.value = false
mockTier.value = 'FREE'
mockShouldUseWorkspaceBilling.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -111,7 +117,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -123,7 +129,7 @@ describe('useSubscriptionDialog', () => {
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -138,7 +144,7 @@ describe('useSubscriptionDialog', () => {
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -149,7 +155,7 @@ describe('useSubscriptionDialog', () => {
})
it('opens the team tab when planMode is forced from a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -159,8 +165,9 @@ describe('useSubscriptionDialog', () => {
expect(props.initialPlanMode).toBe('team')
})
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
it('uses the legacy table (with onChooseTeam) on the legacy billing flow', () => {
mockShouldUseWorkspaceBilling.value = false
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
@@ -170,7 +177,7 @@ describe('useSubscriptionDialog', () => {
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -188,7 +195,7 @@ describe('useSubscriptionDialog', () => {
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = false
const { showPricingTable } = useSubscriptionDialog()
@@ -198,6 +205,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', () => {
mockShouldUseWorkspaceBilling.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', () => {
mockShouldUseWorkspaceBilling.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 +287,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

@@ -2,8 +2,10 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
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
@@ -28,7 +24,7 @@ interface SubscriptionDialogOptions {
}
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
@@ -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
@@ -50,7 +57,7 @@ export const useSubscriptionDialog = () => {
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
if (
flags.teamWorkspacesEnabled &&
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
@@ -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;',
@@ -86,9 +95,10 @@ export const useSubscriptionDialog = () => {
}
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) when team workspaces are enabled. Replaces the old
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
if (flags.teamWorkspacesEnabled) {
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.
// Resolved lazily (not at composable setup): these three composables form
@@ -167,6 +177,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 +248,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

@@ -1,4 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -55,10 +56,14 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
if (useAuth)
if (useAuth) {
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
)
}
return
}

View File

@@ -59,3 +59,8 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
undefined
)

View File

@@ -111,6 +111,7 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -11,21 +11,49 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const env = vi.hoisted(() => {
const state = {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
return state[key]
}
})
return { state, fakeRef }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: env.fakeRef('isActiveSubscription'),
type: env.fakeRef('billingType')
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: {
get teamWorkspacesEnabled() {
return env.state.teamWorkspacesEnabled
},
get userSecretsEnabled() {
return env.state.userSecretsEnabled
}
}
})
}))
@@ -34,8 +62,12 @@ vi.mock('@/composables/useVueFeatureFlags', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return env.state.isCloud
},
get isDesktop() {
return env.state.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -77,6 +109,16 @@ describe('useSettingUI', () => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
Object.assign(env.state, {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
})
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
@@ -137,4 +179,59 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))
beforeEach(() => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
isActiveSubscription: true
})
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
})
it('exposes the legacy plan panel when billing is legacy', () => {
env.state.billingType = 'legacy'
const { defaultCategory, navGroups } = useSettingUI('subscription')
expect(defaultCategory.value.key).toBe('subscription')
expect(navKeys(navGroups.value)).toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('hides the legacy plan panel when billing is workspace', () => {
env.state.billingType = 'workspace'
const { navGroups } = useSettingUI()
expect(navKeys(navGroups.value)).not.toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('never renders the plan panel in more than one tab', () => {
const countSubscription = () => {
const { navGroups } = useSettingUI()
return navKeys(navGroups.value).filter((id) => id === 'subscription')
.length
}
for (const teamWorkspacesEnabled of [true, false]) {
for (const billingType of ['legacy', 'workspace'] as const) {
for (const isLoggedIn of [true, false]) {
Object.assign(env.state, {
teamWorkspacesEnabled,
billingType,
isLoggedIn
})
expect(countSubscription()).toBeLessThanOrEqual(1)
}
}
}
})
})
})

View File

@@ -53,7 +53,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription, type: billingType } = useBillingContext()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -157,6 +157,13 @@ export function useSettingUI(
return isActiveSubscription.value
})
const shouldShowLegacyPlanCreditsPanel = computed(
() =>
isLoggedIn.value &&
billingType.value === 'legacy' &&
shouldShowPlanCreditsPanel.value
)
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -301,6 +308,9 @@ export function useSettingUI(
label: 'General',
children: [
translateCategory(userPanel.node),
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [translateCategory(subscriptionPanel.node)]
: []),
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
...(shouldShowSecretsPanel.value
? [translateCategory(secretsPanel.node)]
@@ -332,9 +342,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),

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

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

@@ -2,10 +2,8 @@ import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeLocatorId } from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -17,53 +15,6 @@ vi.mock('@/platform/distribution/types', () => ({
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
const {
mockApp,
mockCanvasStore,
mockExecutionIdToNodeLocatorId,
mockGetExecutionIdByNode,
mockGetNodeByExecutionId,
mockWorkflowStore
} = vi.hoisted(() => ({
mockApp: {
isGraphReady: true,
rootGraph: {}
},
mockCanvasStore: {
currentGraph: undefined as object | undefined
},
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: string) => id as NodeLocatorId
),
mockGetExecutionIdByNode: vi.fn(),
mockGetNodeByExecutionId: vi.fn(),
mockWorkflowStore: {
nodeLocatorIdToNodeId: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args),
forEachNode: vi.fn(),
getExecutionIdByNode: (
...args: Parameters<typeof mockGetExecutionIdByNode>
) => mockGetExecutionIdByNode(...args),
getNodeByExecutionId: (
...args: Parameters<typeof mockGetNodeByExecutionId>
) => mockGetNodeByExecutionId(...args)
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
@@ -88,22 +39,6 @@ import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { toNodeId } from '@/types/nodeId'
beforeEach(() => {
mockShowErrorsTab.value = false
mockApp.isGraphReady = true
mockCanvasStore.currentGraph = undefined
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: string) => id as NodeLocatorId
)
mockGetExecutionIdByNode.mockReset()
mockGetNodeByExecutionId.mockReset()
mockWorkflowStore.nodeLocatorIdToNodeId.mockReset()
mockWorkflowStore.nodeLocatorIdToNodeId.mockImplementation(
(locator: NodeLocatorId) =>
toNodeId(String(locator).split(':').at(-1) ?? locator)
)
})
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -209,31 +144,6 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('does nothing when the requested slot has no errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: 'Max exceeded',
details: '',
extra_info: { input_name: 'otherSlot' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
'testSlot'
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('preserves complex errors when slot has both simple and complex errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
@@ -478,358 +388,6 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors).not.toBeNull()
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('keeps numeric range errors when no range options prove them valid', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('clears simple widget errors when the numeric value has no node error entry', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'999': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15,
{ max: 10 }
)
expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1)
})
})
describe('startup clearing', () => {
it('clears execution-start errors and closes the overlay when node errors are empty', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.lastExecutionError).toBeNull()
expect(store.lastPromptError).toBeNull()
expect(store.isErrorOverlayOpen).toBe(false)
})
it('keeps the overlay open when node errors remain after execution start', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.isErrorOverlayOpen).toBe(true)
})
})
})
describe('executionErrorStore derived graph state', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('derives execution error node ids through locator mapping', () => {
const store = useExecutionErrorStore()
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, string>('graph:7')
)
store.lastExecutionError = fromAny({ node_id: '7' })
expect(store.lastExecutionErrorNodeId).toBe(toNodeId(7))
})
it('returns null when there is no execution error locator', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '7' })
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, undefined>(undefined)
)
expect(store.lastExecutionErrorNodeId).toBeNull()
})
it('returns null when there is no execution error', () => {
const store = useExecutionErrorStore()
expect(store.lastExecutionErrorNodeId).toBeNull()
})
it('combines prompt, node, execution, and missing-node error counts', () => {
const store = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastExecutionError = fromAny({ node_id: null })
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
},
{
type: 'value_bigger_than_max',
message: 'Too large',
details: '',
extra_info: { input_name: 'y' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
missingNodesStore.setMissingNodeTypes(
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
)
expect(store.hasPromptError).toBe(true)
expect(store.hasNodeError).toBe(true)
expect(store.hasExecutionError).toBe(true)
expect(store.hasAnyError).toBe(true)
expect(store.allErrorExecutionIds).toEqual(['1'])
expect(store.totalErrorCount).toBe(5)
})
it('reports empty derived state when there are no errors', () => {
const store = useExecutionErrorStore()
expect(store.hasNodeError).toBe(false)
expect(store.allErrorExecutionIds).toEqual([])
expect(store.totalErrorCount).toBe(0)
})
it('includes defined execution node ids in the error id list', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '2' })
expect(store.allErrorExecutionIds).toEqual(['2'])
})
it('excludes undefined execution node ids from the error id list', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: undefined })
expect(store.allErrorExecutionIds).toEqual([])
})
it('collects active graph node ids for validation and execution errors', () => {
const store = useExecutionErrorStore()
const activeGraph = {}
mockCanvasStore.currentGraph = activeGraph
mockGetNodeByExecutionId.mockImplementation((_rootGraph, id: string) => ({
id: toNodeId(id),
graph: activeGraph
}))
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.lastExecutionError = fromAny({ node_id: '2' })
expect([...store.activeGraphErrorNodeIds].sort()).toEqual(['1', '2'])
})
it('falls back to the root graph when there is no current canvas graph', () => {
const store = useExecutionErrorStore()
mockCanvasStore.currentGraph = undefined
mockGetNodeByExecutionId.mockReturnValue({
id: toNodeId(1),
graph: mockApp.rootGraph
})
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
expect([...store.activeGraphErrorNodeIds]).toEqual(['1'])
})
it('ignores graph errors outside the active graph', () => {
const store = useExecutionErrorStore()
const activeGraph = {}
mockCanvasStore.currentGraph = activeGraph
mockGetNodeByExecutionId.mockReturnValue({
id: toNodeId(1),
graph: {}
})
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.lastExecutionError = fromAny({ node_id: '1' })
expect(store.activeGraphErrorNodeIds.size).toBe(0)
})
it('returns no active graph node ids before the graph is ready', () => {
const store = useExecutionErrorStore()
mockApp.isGraphReady = false
store.lastExecutionError = fromAny({ node_id: '2' })
expect(store.activeGraphErrorNodeIds.size).toBe(0)
})
it('maps node errors by locator and checks slots', () => {
const store = useExecutionErrorStore()
const nodeError = {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
mockExecutionIdToNodeLocatorId.mockImplementation((_rootGraph, id) =>
id === 'missing'
? fromAny<NodeLocatorId, undefined>(undefined)
: fromAny<NodeLocatorId, string>(`locator:${id}`)
)
store.lastNodeErrors = {
'1': nodeError,
missing: nodeError
}
const locator = fromAny<NodeLocatorId, string>('locator:1')
expect(store.getNodeErrors(locator)).toEqual(nodeError)
expect(store.slotHasError(locator, 'x')).toBe(true)
expect(store.slotHasError(locator, 'y')).toBe(false)
expect(
store.getNodeErrors(fromAny<NodeLocatorId, string>('locator:missing'))
).toBeUndefined()
})
it('returns no slot error when there are no node errors', () => {
const store = useExecutionErrorStore()
expect(
store.slotHasError(fromAny<NodeLocatorId, string>('locator:1'), 'x')
).toBe(false)
})
it('detects container nodes with internal errors', () => {
const store = useExecutionErrorStore()
const node = fromAny<LGraphNode, unknown>({})
mockGetExecutionIdByNode.mockReturnValueOnce(undefined)
expect(store.isContainerWithInternalError(node)).toBe(false)
store.lastNodeErrors = {
'1:2': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
mockGetExecutionIdByNode.mockReturnValue(
createNodeExecutionId([toNodeId(1)])
)
expect(store.isContainerWithInternalError(node)).toBe(true)
})
it('does not report container errors before the graph is ready', () => {
const store = useExecutionErrorStore()
mockApp.isGraphReady = false
expect(
store.isContainerWithInternalError(fromAny<LGraphNode, unknown>({}))
).toBe(false)
})
})
@@ -899,23 +457,6 @@ describe('surfaceMissingModels — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingModels([
fromAny({
name: 'model.safetensors',
nodeId: toNodeId('1'),
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('surfaceMissingMedia — silent option', () => {
@@ -984,23 +525,6 @@ describe('surfaceMissingMedia — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingMedia([
fromAny({
name: 'photo.png',
nodeId: toNodeId('1'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('clearAllErrors', () => {

View File

@@ -1,120 +0,0 @@
import { createPinia, 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 } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
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: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
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
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow,
nodes: string[] = []
) {
openSet.add(wf)
store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf })
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore interrupt and cached', () => {
it('drops the workflow badge and goes idle on interruption', () => {
const store = setup()
const wf = workflow('a.json')
startJob(store, 'job-1', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(store.isIdle).toBe(true)
})
it('ends the active job when executing resolves to null', () => {
const store = setup()
startJob(store, 'job-2', workflow('b.json'))
expect(store.isIdle).toBe(false)
handlers['executing']?.({ detail: null })
expect(store.isIdle).toBe(true)
})
it('marks cached nodes as executed', () => {
const store = setup()
startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c'])
expect(store.nodesExecuted).toBe(0)
handlers['execution_cached']?.({
detail: { prompt_id: 'job-3', nodes: ['a', 'b'] }
})
expect(store.nodesExecuted).toBe(2)
})
})

View File

@@ -1,119 +0,0 @@
import { createPinia, 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 } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
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: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
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
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
nodes: string[]
) {
store.storeJob({
nodes,
id,
promptOutput: promptOutput(),
workflow: { path: `${id}.json` } as unknown as ComfyWorkflow
})
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore execution lifecycle', () => {
it('reports zero progress while idle', () => {
const store = setup()
expect(store.totalNodesToExecute).toBe(0)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('counts the queued nodes once a job starts', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
expect(store.totalNodesToExecute).toBe(3)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('advances progress as executed events arrive', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(1)
expect(store.executionProgress).toBeCloseTo(1 / 3)
handlers['executed']?.({ detail: { node: 'b' } })
handlers['executed']?.({ detail: { node: 'c' } })
expect(store.nodesExecuted).toBe(3)
expect(store.executionProgress).toBe(1)
})
it('ignores executed events when there is no active job', () => {
const store = setup()
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(0)
})
})

View File

@@ -1,128 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
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: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
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
}))
function progressState(
jobId: string,
nodes: Record<string, Partial<NodeProgressState>>
) {
handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore node progress', () => {
it('is idle until an execution starts', () => {
const store = setup()
expect(store.isIdle).toBe(true)
handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } })
expect(store.isIdle).toBe(false)
})
it('derives the running node ids from a progress_state event', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 },
n2: { state: 'finished' },
n3: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual(['n1'])
expect(store.executingNodeId).toBe('n1')
})
it('exposes fractional progress for the executing node', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 }
})
expect(store.executingNodeProgress).toBe(0.25)
})
it('reports no executing node when none are running', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'finished' },
n2: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual([])
expect(store.executingNodeId).toBeNull()
})
it('replaces progress state on each progress_state event', () => {
const store = setup()
progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } })
expect(store.executingNodeId).toBe('n1')
progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } })
expect(store.executingNodeIds).toEqual(['n2'])
})
})

View File

@@ -1,173 +0,0 @@
import { createPinia, 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'
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
type CloudValidationResult = ReturnType<typeof classifyCloudValidationError>
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
activeWorkflow: { value: null as { path: string } | null },
dist: { isCloud: false },
classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null)
}))
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: () => true,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null,
get activeWorkflow() {
return activeWorkflow.value
}
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => errorStore
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
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: () => null
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
activeWorkflow.value = null
dist.isCloud = false
classifyCloud.mockReturnValue(null)
for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError'])
delete errorStore[k]
})
describe('executionStore running state and error edges', () => {
it('lists jobs with a running node and counts running workflows', () => {
const store = setup()
handlers['progress_state']?.({
detail: {
prompt_id: 'job-1',
nodes: { n1: { state: 'running', value: 1, max: 2 } }
}
})
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
it('does not report the active workflow as running when the path differs', () => {
const store = setup()
expect(store.isActiveWorkflowRunning).toBe(false)
const wf = workflow('w.json')
activeWorkflow.value = { path: 'other.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('reports the active workflow as running when job, path and session agree', () => {
const store = setup()
const wf = workflow('w.json')
activeWorkflow.value = { path: 'w.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(true)
})
it('formats a service-level error message from the exception message alone', () => {
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'error',
message: 'Job has stagnated',
details: ''
})
})
it('stores a classified cloud prompt error on the prompt-error branch', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'promptError',
promptError: { type: 'validation', message: 'bad input', details: '' }
})
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-4', exception_message: '{}' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'validation',
message: 'bad input',
details: ''
})
})
})

View File

@@ -1,153 +0,0 @@
import { createPinia, 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 } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
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: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
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
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function storeJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow
) {
store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf })
}
function fire(event: string, jobId: string) {
handlers[event]?.({ detail: { prompt_id: jobId } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore workflow status', () => {
it('marks an open workflow running on execution_start and completed on success', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
storeJob(store, 'job-1', wf)
fire('execution_start', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('running')
fire('execution_success', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => {
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
fire('execution_start', 'job-2')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
storeJob(store, 'job-2', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
})
it('does not apply status to a workflow that is not open', () => {
const store = setup()
const wf = workflow('c.json')
storeJob(store, 'job-3', wf)
fire('execution_start', 'job-3')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('clears a workflow status', () => {
const store = setup()
const wf = workflow('d.json')
openSet.add(wf)
storeJob(store, 'job-4', wf)
fire('execution_start', 'job-4')
expect(store.getWorkflowStatus(wf)).toBe('running')
store.clearWorkflowStatus(wf)
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('does not let a late buffered running overwrite a terminal status', () => {
const store = setup()
const wf = workflow('e.json')
openSet.add(wf)
storeJob(store, 'job-5', wf)
fire('execution_success', 'job-5')
expect(store.getWorkflowStatus(wf)).toBe('completed')
fire('execution_start', 'job-6')
storeJob(store, 'job-6', wf)
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('returns undefined for a null or unknown workflow', () => {
const store = setup()
expect(store.getWorkflowStatus(null)).toBeUndefined()
expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined()
})
})

View File

@@ -1,139 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level
// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it.
vi.mock('@/scripts/app', () => ({ app: {} }))
// Keep preview-url assertions deterministic: don't append cloud params.
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
interface ItemOverrides {
filename?: string
mediaType?: string
format?: string
frame_rate?: number
}
function item(over: ItemOverrides = {}) {
return new ResultItemImpl({
filename: over.filename ?? 'out.png',
subfolder: 'sub',
type: 'output',
nodeId: '1' as SerializedNodeId,
mediaType: over.mediaType ?? 'images',
format: over.format,
frame_rate: over.frame_rate
})
}
describe('ResultItemImpl', () => {
it('builds view url params and omits absent vhs fields', () => {
const params = item({ filename: 'a.png' }).urlParams
expect(params.get('filename')).toBe('a.png')
expect(params.get('type')).toBe('output')
expect(params.get('subfolder')).toBe('sub')
expect(params.has('format')).toBe(false)
expect(params.has('frame_rate')).toBe(false)
})
it('includes vhs format and frame_rate params when present', () => {
const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams
expect(params.get('format')).toBe('video/h264-mp4')
expect(params.get('frame_rate')).toBe('24')
})
it('returns an empty url for a nameless item and a view url otherwise', () => {
expect(item({ filename: '' }).url).toBe('')
expect(item({ filename: 'a.png' }).url).toContain('/view?')
})
it('routes image preview urls through /view', () => {
expect(
item({ filename: 'a.png', mediaType: 'images' }).previewUrl
).toContain('/view?')
})
it('exposes the vhs advanced preview endpoint', () => {
expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?')
})
it('maps html video mime types by suffix and vhs format', () => {
expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm')
expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4')
expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime')
expect(
item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 })
.htmlVideoType
).toBe('video/mp4')
expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined()
})
it('maps html audio mime types by suffix', () => {
expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg')
expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav')
expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg')
expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac')
expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined()
})
it('treats vhs format as such only with both format and frame_rate', () => {
expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true)
expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false)
})
it('classifies video by suffix and by media type', () => {
expect(item({ filename: 'a.webm' }).isVideo).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false)
})
it('classifies image only when not contradicted by a media suffix', () => {
expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true)
expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe(
false
)
})
it('classifies audio by suffix and by media type', () => {
expect(item({ filename: 'a.mp3' }).isAudio).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false)
})
it('reports text and preview support', () => {
const text = item({ filename: 'a.txt', mediaType: 'text' })
expect(text.isText).toBe(true)
expect(text.supportsPreview).toBe(true)
expect(item({ filename: 'a.png' }).supportsPreview).toBe(true)
expect(
item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview
).toBe(false)
})
it('filters previewable outputs and finds an item by url', () => {
const png = item({ filename: 'a.png' })
const mp3 = item({ filename: 'b.mp3', mediaType: 'audio' })
const bin = item({ filename: 'a.bin', mediaType: 'binary' })
expect(ResultItemImpl.filterPreviewable([png, mp3, bin])).toEqual([
png,
mp3
])
expect(ResultItemImpl.findByUrl([png, mp3, bin], png.url)).toBe(0)
expect(ResultItemImpl.findByUrl([png, mp3, bin], mp3.url)).toBe(1)
expect(ResultItemImpl.findByUrl([png, mp3, bin], 'no-match')).toBe(0)
expect(ResultItemImpl.findByUrl([png, mp3, bin])).toBe(0)
})
})

View File

@@ -1,210 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() }))
vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput }))
beforeEach(() => {
parseTaskOutput.mockClear()
})
type JobStatus =
| 'in_progress'
| 'pending'
| 'completed'
| 'failed'
| 'cancelled'
function executionError(
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
): NonNullable<JobListItem['execution_error']> {
return {
node_id: '1',
node_type: 'KSampler',
exception_message: 'boom',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {},
...overrides
}
}
function job(over: Partial<JobListItem> = {}): JobListItem {
return {
id: 'job-1',
status: 'completed',
create_time: 1000,
priority: 0,
...over
}
}
function result(filename: string, type: ResultItemType = 'output') {
return new ResultItemImpl({
filename,
subfolder: '',
type,
nodeId: '1' as SerializedNodeId,
mediaType: 'images'
})
}
describe('TaskItemImpl', () => {
it('maps job status to taskType and apiTaskType', () => {
expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe(
'Running'
)
expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe(
'Pending'
)
expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe(
'History'
)
expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe(
'queue'
)
expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe(
'history'
)
})
it('exposes displayStatus for every backend status', () => {
const statuses: [JobStatus, string][] = [
['in_progress', 'Running'],
['pending', 'Pending'],
['completed', 'Completed'],
['failed', 'Failed'],
['cancelled', 'Cancelled']
]
for (const [status, display] of statuses) {
expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display)
}
})
it('derives history/running flags and a status-qualified key', () => {
const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' }))
expect(running.isRunning).toBe(true)
expect(running.isHistory).toBe(false)
expect(running.key).toBe('aRunning')
expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true)
})
it('uses explicitly provided flat outputs', () => {
const outputs = [result('a.png')]
const task = new TaskItemImpl(job(), undefined, outputs)
expect(task.flatOutputs).toBe(outputs)
})
it('parses outputs lazily when flat outputs are not supplied', () => {
const parsed = [result('p.png')]
parseTaskOutput.mockReturnValueOnce(parsed)
const outputs: TaskOutput = { '1': { images: [] } }
const task = new TaskItemImpl(job(), outputs)
expect(parseTaskOutput).toHaveBeenCalled()
expect(task.flatOutputs).toBe(parsed)
})
it('synthesizes outputs from preview_output when none are provided', () => {
parseTaskOutput.mockReturnValueOnce([])
const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' }
new TaskItemImpl(job({ preview_output: preview }))
expect(parseTaskOutput).toHaveBeenCalledWith({
'5': { images: [preview] }
})
})
it('prefers the last saved output over temp previews for previewOutput', () => {
const temp = result('temp.png', 'temp')
const saved = result('saved.png', 'output')
const task = new TaskItemImpl(job(), undefined, [temp, saved])
expect(task.previewOutput).toBe(saved)
const onlyTemp = new TaskItemImpl(job(), undefined, [temp])
expect(onlyTemp.previewOutput).toBe(temp)
})
it('reports interrupted only for an interrupt-typed failure', () => {
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({
exception_type: 'InterruptProcessingException'
})
})
).interrupted
).toBe(true)
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({ exception_type: 'Other' })
})
).interrupted
).toBe(false)
})
it('surfaces error message and passthrough job fields', () => {
const task = new TaskItemImpl(
job({
status: 'failed',
outputs_count: 3,
workflow_id: 'wf-9',
execution_error: executionError({ exception_message: 'boom' })
})
)
expect(task.errorMessage).toBe('boom')
expect(task.outputsCount).toBe(3)
expect(task.workflowId).toBe('wf-9')
})
it('computes execution time only when both timestamps exist', () => {
expect(
new TaskItemImpl(
job({ execution_start_time: 1000, execution_end_time: 3000 })
).executionTimeInSeconds
).toBe(2)
expect(
new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime
).toBeUndefined()
})
it('flatten returns itself when not completed', () => {
const running = new TaskItemImpl(job({ status: 'in_progress' }))
expect(running.flatten()).toEqual([running])
})
it('flatten expands a completed task into one task per output', () => {
const outputs = [result('a.png'), result('b.png')]
const task = new TaskItemImpl(
job({ id: 'j', status: 'completed' }),
undefined,
outputs
)
const flattened = task.flatten()
expect(flattened).toHaveLength(2)
expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1'])
})
})

View File

@@ -1,4 +1,4 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
@@ -154,22 +154,6 @@ describe(parseNodeOutput, () => {
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
it('excludes non-object and invalid-type items', () => {
const output = fromAny<NodeExecutionOutput, unknown>({
images: [
null,
'not-an-item',
{ filename: 'bad.png', type: 'invalid' },
{ filename: 'valid.png', type: 'output' }
]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})
describe(parseTaskOutput, () => {

View File

@@ -25,13 +25,15 @@ export enum ServerFeatureFlag {
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
}
}
}

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"

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
type NodePack = components['schemas']['Node']
type ManagerStoreReturn = ReturnType<typeof useComfyManagerStore>
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockIsPackInstalled = vi.fn()
const mockIsPackEnabled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
function makePack(overrides: Partial<NodePack> = {}): NodePack {
return {
id: 'pack-1',
name: 'Pack',
latest_version: { version: '2.0.0' },
...overrides
} as NodePack
}
beforeEach(() => {
vi.clearAllMocks()
mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true)
mockGetInstalledPackVersion.mockReturnValue('1.0.0')
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
isPackEnabled: mockIsPackEnabled,
getInstalledPackVersion: mockGetInstalledPackVersion
} as Partial<ManagerStoreReturn> as ManagerStoreReturn)
})
describe('usePackUpdateStatus', () => {
it('reports an update when the latest release is newer', () => {
const { isUpdateAvailable, installedVersion, latestVersion } =
usePackUpdateStatus(makePack())
expect(isUpdateAvailable.value).toBe(true)
expect(installedVersion.value).toBe('1.0.0')
expect(latestVersion.value).toBe('2.0.0')
})
it('reports no update when installed matches latest', () => {
mockGetInstalledPackVersion.mockReturnValue('2.0.0')
const { isUpdateAvailable } = usePackUpdateStatus(makePack())
expect(isUpdateAvailable.value).toBe(false)
})
it('reports no update when the pack is not installed', () => {
mockIsPackInstalled.mockReturnValue(false)
mockGetInstalledPackVersion.mockReturnValue(undefined)
const { isUpdateAvailable } = usePackUpdateStatus(makePack())
expect(isUpdateAvailable.value).toBe(false)
})
it('treats a non-semver installed version as a nightly build', () => {
mockGetInstalledPackVersion.mockReturnValue('abc1234')
const { isNightlyPack, isUpdateAvailable, canTryNightlyUpdate } =
usePackUpdateStatus(makePack())
expect(isNightlyPack.value).toBe(true)
expect(isUpdateAvailable.value).toBe(false)
expect(canTryNightlyUpdate.value).toBe(true)
})
it('only allows a nightly update when installed and enabled', () => {
mockGetInstalledPackVersion.mockReturnValue('abc1234')
mockIsPackEnabled.mockReturnValue(false)
const { canTryNightlyUpdate } = usePackUpdateStatus(makePack())
expect(canTryNightlyUpdate.value).toBe(false)
})
it('reports no update when the registry omits a latest version', () => {
const { isUpdateAvailable } = usePackUpdateStatus(
makePack({ latest_version: undefined })
)
expect(isUpdateAvailable.value).toBe(false)
})
it('recomputes when the source pack changes', async () => {
const source = ref(makePack({ latest_version: { version: '1.0.0' } }))
const { isUpdateAvailable } = usePackUpdateStatus(source)
expect(isUpdateAvailable.value).toBe(false)
source.value = makePack({ latest_version: { version: '3.0.0' } })
await nextTick()
expect(isUpdateAvailable.value).toBe(true)
})
})

View File

@@ -1,42 +1,30 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { compare, valid } from 'semver'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { getPackUpdateStatus } from '@/workbench/extensions/manager/utils/packUpdateStatus'
export const usePackUpdateStatus = (
nodePackSource: MaybeRefOrGetter<components['schemas']['Node']>
) => {
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
useComfyManagerStore()
const managerStore = useComfyManagerStore()
// Use toValue to unwrap the source reactively inside computeds
const nodePack = computed(() => toValue(nodePackSource))
const isInstalled = computed(() => isPackInstalled(nodePack.value?.id))
const isEnabled = computed(() => isPackEnabled(nodePack.value?.id))
const installedVersion = computed(() =>
getInstalledPackVersion(nodePack.value?.id ?? '')
)
const latestVersion = computed(() => nodePack.value?.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !valid(installedVersion.value)
const status = computed(() =>
getPackUpdateStatus(nodePack.value, managerStore)
)
const isUpdateAvailable = computed(() => {
if (
!isInstalled.value ||
isNightlyPack.value ||
!latestVersion.value ||
!installedVersion.value
) {
return false
}
return compare(latestVersion.value, installedVersion.value) > 0
})
const isInstalled = computed(() => status.value.isInstalled)
const isEnabled = computed(() =>
managerStore.isPackEnabled(nodePack.value?.id)
)
const installedVersion = computed(() => status.value.installedVersion)
const latestVersion = computed(() => status.value.latestVersion)
const isNightlyPack = computed(() => status.value.isNightly)
const isUpdateAvailable = computed(() => status.value.isUpdateAvailable)
/**
* Nightly packs can always "try update" since we cannot compare git hashes

View File

@@ -1,9 +1,9 @@
import { valid } from 'semver'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { isNightlyVersion } from '@/workbench/extensions/manager/utils/packUpdateStatus'
type NodePack = components['schemas']['Node']
@@ -50,9 +50,8 @@ export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
installedPacks.value.filter((pack) => {
if (!pack.id) return false
const version = managerStore.getInstalledPackVersion(pack.id)
const isNightly = !!version && !valid(version)
const isEnabled = managerStore.isPackEnabled(pack.id)
return isNightly && isEnabled
return isNightlyVersion(version) && isEnabled
})
)

Some files were not shown because too many files have changed in this diff Show More