Compare commits

..

11 Commits

Author SHA1 Message Date
Comfy Org PR Bot
3a7ea05851 1.47.9 (#13674)
Patch version increment to 1.47.9

**Base branch:** `core/1.47`

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-07-14 20:44:10 -07:00
Alexander Brown
6c5f9a17c4 hotfix: Add vue-i18n literal escapes for NodeDef descriptions (#13665)
Future generations should be taken care of with the new processing.
2026-07-14 19:37:22 -07:00
Comfy Org PR Bot
2912e167d3 [backport core/1.47] fix(release): support manual target-branch override + major versions in resolve-comfyui-release (#13661)
Backport of #13660 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:34:14 +09:00
Comfy Org PR Bot
d677cc6404 1.47.8 (#13663)
Patch version increment to 1.47.8

**Base branch:** `core/1.47`

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-07-14 17:26:41 -07:00
Comfy Org PR Bot
b0f2595efe [backport core/1.47] fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13651)
Backport of #13631 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-15 07:23:44 +09:00
Comfy Org PR Bot
6ec75d6985 [backport core/1.47] feat: lift boundary-exposed validation errors to the subgraph host (#13637)
Backport of #13542 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-07-15 03:31:33 +09:00
Comfy Org PR Bot
7cc27b07fa [backport core/1.47] test: pin unknown-provider flow-through in server-driven provider options (#13623)
Backport of #13546 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-07-13 16:07:55 -07:00
Comfy Org PR Bot
51ea158b15 [backport core/1.47] fix(dialog): keep Settings open when nested dialogs shift focus + BYOK secrets E2E (#13621)
Backport of #13510 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:05:47 -07:00
Comfy Org PR Bot
0cb2b61ece [backport core/1.47] feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13582)
Backport of #13376 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-11 02:52:25 +09:00
Comfy Org PR Bot
8529adfeb1 [backport core/1.47] feat: wire up Save 3D (Advanced) node family (CORE-329) (#13580)
Backport of #13330 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-11 02:08:27 +09:00
Comfy Org PR Bot
5374aa7ee1 [backport core/1.47] On workflow swap, restore 'Preview as Text' text (#13553)
Backport of #13536 to `core/1.47`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 15:46:49 -07:00
56 changed files with 673 additions and 3578 deletions

View File

@@ -8,13 +8,11 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {

View File

@@ -1,21 +0,0 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class FreeTierQuota {
readonly root: Locator
constructor(comfyPage: ComfyPage) {
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
}
async getMax() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[2]
}
async getAvailable() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[1]
}
}

View File

@@ -103,8 +103,7 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons',
freeTierQuota: 'free-tier-quota'
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

@@ -1,63 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
test.beforeEach(async ({ page }) => {
const features = {
free_tier_job_allowance_enabled: true,
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
}
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
})
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const freeTierQuota = new FreeTierQuota(comfyPage)
await test.step('Populates initial state from config', async () => {
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
expect(await freeTierQuota.getMax()).toBe('5')
})
await test.step('available decrements on run', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('connects to detached run button', async () => {
const handle = comfyPage.actionbar.dragHandle
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
expect(await freeTierQuota.getAvailable()).toBe('2')
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
})
await test.step('Detects workflows with Partner nodes', async () => {
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await node.delete()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('Does not decrease past 0', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await execution.run()
await execution.run()
await execution.run()
await comfyPage.nextFrame()
expect(await freeTierQuota.getAvailable()).toBe(undefined)
})
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.7",
"version": "1.47.9",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -40,11 +40,6 @@ export type ComfyDesktop2TelemetryProperties = Record<
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export type ComfyDesktop2FirebaseAuthState =
| { status: 'pending' }
| { status: 'signed_out' }
| { status: 'signed_in'; userId: string }
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
@@ -65,7 +60,6 @@ export interface ComfyDesktop2LogsBridge {
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
}
export interface ComfyDesktop2Bridge {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.3",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",

View File

@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).not.toContain('w-0')
expect(actionbarContainer!.classList).toContain('px-2')
} finally {
unmount()
vi.unstubAllGlobals()

View File

@@ -11,7 +11,7 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-start gap-2">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
@@ -34,75 +34,61 @@
</Button>
</div>
<div
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative flex items-center gap-2',
isActionbarContainerEmpty &&
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
<FreeTierQuota v-if="!isActionbarFloating" />
</div>
</div>
<ErrorOverlay />
@@ -161,7 +147,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -224,6 +209,21 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)

View File

@@ -75,7 +75,6 @@
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
<FreeTierQuota v-if="!isDocked" />
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
@@ -110,7 +109,6 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockCanRunWorkflows = ref(true)
const mockIsActiveSubscription = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
canRunWorkflows: mockCanRunWorkflows
isActiveSubscription: mockIsActiveSubscription
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockCanRunWorkflows.value = true
mockIsActiveSubscription.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockCanRunWorkflows.value = false
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockCanRunWorkflows.value = false
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockCanRunWorkflows.value = true
mockIsActiveSubscription.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="canRunWorkflows ? 'queue' : 'subscribe'"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
const { canRunWorkflows } = useBillingContext()
const { isActiveSubscription } = useBillingContext()
const currentButton = computed(() =>
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -8,10 +8,6 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
login: vi.fn().mockResolvedValue(undefined),
loginWithGoogle: vi.fn().mockResolvedValue(undefined),
loginWithGithub: vi.fn().mockResolvedValue(undefined),
register: vi.fn().mockResolvedValue(undefined),
logout: vi.fn().mockResolvedValue(undefined)
}))
@@ -32,12 +28,10 @@ const mockDialogService = vi.hoisted(() => ({
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackAuthFailed = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use',
'auth/user-not-found'
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
@@ -54,9 +48,7 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAuthFailed: mockTrackAuthFailed
}))
useTelemetry: vi.fn(() => undefined)
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -89,19 +81,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync:
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: unknown) => void
) =>
async (...args: TArgs) => {
try {
return await action(...args)
} catch (error) {
;(errorHandler ?? mockToastErrorHandler)(error)
return undefined
}
},
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: mockToastErrorHandler
})
}))
@@ -174,13 +156,10 @@ describe('useAuthActions.logout', () => {
)
const { logout } = useAuthActions()
await logout()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
expect(mockToastErrorHandler).toHaveBeenCalledExactlyOnceWith(
new Error('auth.signOut.saveFailed:a.json')
)
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
@@ -227,85 +206,6 @@ describe('useAuthActions.logout', () => {
})
})
describe('useAuthActions auth flow error telemetry', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('tracks email sign-in Firebase failures and still shows the error toast', async () => {
const error = new FirebaseError('auth/user-not-found', 'msg')
mockAuthStore.login.mockRejectedValueOnce(error)
const { signInWithEmail } = useAuthActions()
await expect(
signInWithEmail('user@example.com', 'password')
).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
})
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/user-not-found'
})
})
it('tracks unknown errors for email sign-up failures', async () => {
const error = new Error('network failed')
mockAuthStore.register.mockRejectedValueOnce(error)
const { signUpWithEmail } = useAuthActions()
await expect(
signUpWithEmail('user@example.com', 'password')
).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'unknown',
auth_action: 'email_sign_up'
})
})
it('tracks Google sign-up failures separately from sign-in failures', async () => {
const error = new FirebaseError('auth/popup-closed-by-user', 'msg')
mockAuthStore.loginWithGoogle.mockRejectedValueOnce(error)
const { signInWithGoogle } = useAuthActions()
await expect(signInWithGoogle({ isNewUser: true })).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/popup-closed-by-user',
auth_action: 'google_sign_up'
})
})
it('tracks GitHub sign-up failures separately from sign-in failures', async () => {
const error = new FirebaseError('auth/popup-closed-by-user', 'msg')
mockAuthStore.loginWithGithub.mockRejectedValueOnce(error)
const { signInWithGithub } = useAuthActions()
await expect(signInWithGithub({ isNewUser: true })).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/popup-closed-by-user',
auth_action: 'github_sign_up'
})
})
it('does not track auth failures for logout failures', async () => {
const error = new FirebaseError('auth/network-request-failed', 'msg')
mockAuthStore.logout.mockRejectedValueOnce(error)
const { logout } = useAuthActions()
await logout()
expect(mockTrackAuthFailed).not.toHaveBeenCalled()
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -8,7 +8,6 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { AuthFlowAction } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -29,15 +28,6 @@ export const useAuthActions = () => {
const accessError = ref(false)
const reportAuthFlowError =
(authAction: AuthFlowAction) => (error: unknown) => {
useTelemetry()?.trackAuthFailed({
error_code: error instanceof FirebaseError ? error.code : 'unknown',
auth_action: authAction
})
reportError(error)
}
const reportError = (error: unknown) => {
// Ref: https://firebase.google.com/docs/auth/admin/errors
if (
@@ -137,7 +127,7 @@ export const useAuthActions = () => {
life: 5000
})
},
reportAuthFlowError('password_reset')
reportError
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
@@ -187,34 +177,32 @@ export const useAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = async (options?: { isNewUser?: boolean }) =>
await wrapWithErrorHandlingAsync(
async () => await authStore.loginWithGoogle(options),
reportAuthFlowError(
options?.isNewUser ? 'google_sign_up' : 'google_sign_in'
)
)()
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGithub = async (options?: { isNewUser?: boolean }) =>
await wrapWithErrorHandlingAsync(
async () => await authStore.loginWithGithub(options),
reportAuthFlowError(
options?.isNewUser ? 'github_sign_up' : 'github_sign_in'
)
)()
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {
return await authStore.login(email, password)
},
reportAuthFlowError('email_sign_in')
reportError
)
const signUpWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string, turnstileToken?: string) => {
return await authStore.register(email, password, turnstileToken)
},
reportAuthFlowError('email_sign_up')
reportError
)
/**

View File

@@ -113,5 +113,4 @@ export interface BillingContext extends BillingState, BillingActions {
*/
isLegacyTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -6,7 +6,6 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -130,16 +129,6 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const freeTierQuota = useFreeTierQuota()
const canRunWorkflows = computed(
() =>
isActiveSubscription.value &&
(!isFreeTier.value ||
!freeTierQuota.quotaEnabled.value ||
freeTierQuota.freeTierExecutionPermitted.value)
)
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -308,7 +297,6 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
billingStatus,

View File

@@ -1,19 +1,12 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, toValue } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -157,20 +150,3 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
export const useCreditsBadgesInGraph = createSharedComposable(() => {
const { isCreditsBadge } = usePriceBadge()
const vueNodeLifecycle = useVueNodeLifecycle()
return computed(() => {
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
if (!app.graph) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
})
})

View File

@@ -33,7 +33,6 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -203,16 +202,6 @@ export function useFeatureFlags() {
cachedConsolidatedBillingEnabled
)
},
get freeTierJobAllowanceEnabled() {
const config = remoteConfig.value as typeof remoteConfig.value & {
free_tier_job_allowance_enabled?: boolean
}
return resolveFlag(
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
config.free_tier_job_allowance_enabled,
false
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -2415,16 +2415,6 @@
"tooltipLearnMore": "Learn more..."
}
},
"desktopLogin": {
"confirmSummary": "Approve desktop sign-in?",
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
"successSummary": "Signed in",
"successDetail": "You can return to the ComfyUI desktop app.",
"expiredSummary": "Desktop sign-in failed",
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
"failedSummary": "Desktop sign-in failed",
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
@@ -3133,52 +3123,57 @@
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {
"title": "Let's get to know you",
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
"intro": "A few quick questions so we can set up ComfyUI for you.",
"otherPlaceholder": "Tell us more",
"intro": "Help us tailor your ComfyUI experience.",
"errors": {
"chooseAnOption": "Please choose an option.",
"selectAtLeastOne": "Please select at least one option.",
"describeAnswer": "Please describe your answer.",
"answerTooLong": "Please keep your answer under {max} characters."
"describeAnswer": "Please describe your answer."
},
"steps": {
"usage": "How do you plan to use ComfyUI?",
"familiarity": "How familiar are you with ComfyUI?",
"intent": "What do you want to create with ComfyUI?",
"source": "Where did you hear about ComfyUI?"
},
"options": {
"usage": {
"personal": "Personal use",
"work": "Work",
"education": "Education (student or educator)"
},
"familiarity": {
"new": "New — never used it",
"starting": "Beginner — following tutorials",
"basics": "Intermediate — comfortable with basics",
"advanced": "Advanced — build and edit workflows",
"expert": "Expert — I help others"
},
"intent": {
"images": "Images",
"video": "Video",
"workflows": "Workflows and pipelines",
"apps_api": "Apps and APIs",
"exploring": "Just exploring",
"other": "Something else",
"otherPlaceholder": "What do you want to make?"
},
"experience": {
"new": "New to ComfyUI",
"some": "I know my way around",
"pro": "I'm a power user"
},
"focus": {
"workflows": "Custom workflows or pipelines",
"custom_nodes": "Custom nodes",
"pipelines": "Automated pipelines",
"products": "Products for others"
"videos": "Videos",
"images": "Images",
"3d_game": "3D assets / game assets",
"audio": "Audio / music",
"apps": "Simplified Apps from workflows",
"api": "API endpoints to run workflows",
"not_sure": "Not sure"
},
"source": {
"social": "Social media",
"friend": "A friend or colleague",
"search": "Web search",
"community": "A community or forum",
"other": "Somewhere else",
"otherPlaceholder": "Where did you find us?"
},
"source_social": {
"youtube": "YouTube",
"reddit": "Reddit",
"twitter": "X (Twitter)",
"twitter": "Twitter / X",
"instagram": "Instagram",
"tiktok": "TikTok",
"linkedin": "LinkedIn",
"discord": "Discord"
"friend": "Friend or colleague",
"search": "Google / search",
"newsletter": "Newsletter or blog",
"conference": "Conference or event",
"discord": "Discord / community",
"github": "GitHub",
"other": "Other"
}
}
},
@@ -3271,11 +3266,10 @@
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_intent": "What do you want to make?",
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
"cloudSurvey_steps_focus": "What are you building?",
"cloudSurvey_steps_source": "How did you find us?",
"cloudSurvey_steps_source_social": "Which platform?",
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
@@ -3528,9 +3522,6 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},

View File

@@ -1168,7 +1168,7 @@
},
"ByteDanceSeedAudio": {
"display_name": "ByteDance Seed Audio 1.0",
"description": "Generate speech, music, sound effects and multi-speaker dialogue from a single prompt with ByteDance Seed Audio 1.0. Describe the voice(s), emotion, ambience, background music and sound effects in the prompt, and include the lines to speak. Optionally pick a built-in preset voice, clone voices from up to 3 reference clips (tagged @Audio1-3 in the prompt), or derive a voice from a character image. Up to 2 minutes of audio per run.",
"description": "Generate speech, music, sound effects and multi-speaker dialogue from a single prompt with ByteDance Seed Audio 1.0. Describe the voice(s), emotion, ambience, background music and sound effects in the prompt, and include the lines to speak. Optionally pick a built-in preset voice, clone voices from up to 3 reference clips (tagged {'@'}Audio1-3 in the prompt), or derive a voice from a character image. Up to 2 minutes of audio per run.",
"inputs": {
"text_prompt": {
"name": "text_prompt",
@@ -1176,7 +1176,7 @@
},
"reference_mode": {
"name": "reference_mode",
"tooltip": "How to condition the voice: 'text only' (describe everything in the prompt), 'audio reference' (clone up to 3 voices, tagged @Audio1-3), 'image reference' (derive a voice from one character image), or 'preset voice' (pick a built-in named voice that reads the prompt)."
"tooltip": "How to condition the voice: 'text only' (describe everything in the prompt), 'audio reference' (clone up to 3 voices, tagged {'@'}Audio1-3), 'image reference' (derive a voice from one character image), or 'preset voice' (pick a built-in named voice that reads the prompt)."
},
"sample_rate": {
"name": "sample_rate",

View File

@@ -19,7 +19,6 @@ import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
@@ -142,10 +141,6 @@ app
modules: [VueFireAuth()]
})
if (isCloud && hasHostTelemetryBridge) {
syncHostUserIdWithFirebaseAuth()
}
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,

View File

@@ -1,12 +1,5 @@
<template>
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<h1
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ $t('cloudOnboarding.survey.title') }}
</h1>
<div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col">
<DynamicSurveyForm
:key="activeSurvey.version"
:survey="activeSurvey"

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import CloudTemplate from './CloudTemplate.vue'
const renderWithMeta = async (meta: Record<string, unknown>) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'test', component: CloudTemplate, meta }]
})
await router.push('/')
await router.isReady()
return render(CloudTemplate, {
global: {
plugins: [router],
stubs: {
CloudHeroCarousel: { template: '<div data-testid="hero" />' },
CloudTemplateFooter: true
}
}
})
}
describe('CloudTemplate', () => {
it('shows the hero carousel when the route does not hide it', async () => {
await renderWithMeta({})
expect(screen.getByTestId('hero')).toBeInTheDocument()
})
it('hides the hero carousel when route.meta.hideHero is set', async () => {
await renderWithMeta({ hideHero: true })
expect(screen.queryByTestId('hero')).not.toBeInTheDocument()
})
})

View File

@@ -13,22 +13,15 @@
</div>
<CloudTemplateFooter />
</div>
<div
v-if="!route.meta.hideHero"
class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block"
>
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
<CloudHeroCarousel />
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
const route = useRoute()
</script>
<style>
@import '../assets/css/fonts.css';

View File

@@ -5,22 +5,20 @@
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
class="cursor-pointer text-sm text-primary-comfy-canvas/60 no-underline"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -1,618 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
/**
* Every test drives a real in-memory router and the real preserved-query
* manager: the tracker strips the code from the URL at capture time, so the
* stash is the only carrier, and redemption fires from router.afterEach, an
* auth watcher, and a delayed retry after a transient failure.
*
* The fake clock (installed for every test) keeps those retry timers from
* leaking into later tests: afterEach discards them with vi.useRealTimers().
*/
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
interface MockAuthStore {
currentUser: {
uid: string
getIdToken: (forceRefresh?: boolean) => Promise<string>
} | null
getIdToken: () => Promise<string>
}
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
// Reactive so the module's watcher on currentUser fires without a navigation.
// The mock factory is cached across vi.resetModules(), so it reads a holder
// refilled per test; watchers leaked by earlier module generations stay
// subscribed to earlier stores and remain dormant.
const authStoreHolder = vi.hoisted(() => ({
store: null as MockAuthStore | null
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreHolder.store
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
const VALID_CODE = `dlc_${'A'.repeat(43)}`
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
const NAMESPACE = 'desktop_login'
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
const RETRY_DELAY_MS = 5_000
const mockFetch = vi.fn()
let mockAuthStore: MockAuthStore
function okResponse() {
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
}
function expectedFetchOptions(code: string) {
return {
method: 'POST',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code }),
signal: expect.any(AbortSignal)
}
}
// The triggers fire-and-forget the redemption; a zero-length advance of the
// fake clock yields the event loop so the whole mocked promise chain settles.
async function flushRedemption() {
await vi.advanceTimersByTimeAsync(0)
}
// vi.resetModules() also resets the preserved-query manager's in-memory map,
// so the manager must be imported alongside the module under test.
async function setup() {
const { installDesktopLoginRedemption } =
await import('./desktopLoginRedemption')
const { capturePreservedQuery, getPreservedQueryParam } =
await import('@/platform/navigation/preservedQueryManager')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
installDesktopLoginRedemption(router)
let navigationCount = 0
const trigger = async () => {
await router.push(`/trigger-${navigationCount++}`)
await flushRedemption()
}
return {
router,
trigger,
seedStash: (code: string) =>
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
'desktop_login_code'
]),
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
}
}
describe('installDesktopLoginRedemption', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockReset()
mockConfirm.mockResolvedValue(true)
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
mockAuthStore = reactive({
currentUser: {
uid: 'user-1',
getIdToken: mockUserGetIdToken
},
getIdToken: mockStoreGetIdToken
})
authStoreHolder.store = mockAuthStore
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing on navigation when no code is stashed', async () => {
const { trigger } = await setup()
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockConfirm).toHaveBeenCalledWith({
title: 'desktopLogin.confirmSummary',
message: 'desktopLogin.confirmMessage'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'desktopLogin.successSummary',
detail: 'desktopLogin.successDetail',
life: 4000
})
})
it('does not fetch before the user approves the confirmation dialog', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
expect(mockFetch).not.toHaveBeenCalled()
approve(true)
await flushRedemption()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it.for([
['declines', false],
['dismisses', null]
] as const)(
'clears the stash without a request or toast when the user %s the dialog',
async ([_label, confirmResult]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockResolvedValue(confirmResult)
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
// Declining is final for that code: re-capturing it never re-prompts.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
}
)
it('asks for approval at most once per code across transient retries', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('redeems a code hydrated lazily from sessionStorage', async () => {
const { trigger } = await setup()
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ desktop_login_code: VALID_CODE })
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
})
it('does not redeem or prompt again after a successful redemption', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
// A later navigation re-captures the already-redeemed code.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBeUndefined()
})
it.for([400, 403, 404, 409, 410])(
'clears the stash, shows an error toast, and never retries on %s',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.expiredSummary',
detail: 'desktopLogin.expiredDetail',
life: 6000
})
}
)
it.for([401, 500])(
'keeps the stash on %s for the scheduled retry without a toast',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
}
)
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.failedSummary',
detail: 'desktopLogin.failedDetail',
life: 6000
})
})
it('forces a token refresh on the retry after a 401', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(okResponse())
await trigger()
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('passes a timeout signal and treats an aborted request as transient', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockRejectedValue(
new DOMException('The operation timed out.', 'TimeoutError')
)
await trigger()
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('treats an id token failure as transient without a toast', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
// authStore.getIdToken surfaces failures through a modal error dialog,
// which this background flow must never trigger.
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('clears the stash without a dialog or request for a malformed code', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash('not-a-desktop-login-code')
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
})
it('contains an unexpected internal error instead of rejecting', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
await expect(trigger()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[DesktopLoginRedemption] Redemption failed:',
expect.any(Error)
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockAuthStore.currentUser = null
mockFetch.mockResolvedValue(okResponse())
// The first completed navigation installs the watcher; without a session
// nothing redeems and the stash is kept.
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
// A session appearing without any further navigation redeems via the
// watcher.
mockAuthStore.currentUser = {
uid: 'user-1',
getIdToken: mockUserGetIdToken
}
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
] as const)(
'gives a second code its own dialog and request after the first code %s',
async ([_label, arrangeFirstOutcome]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
arrangeFirstOutcome()
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
seedStash(SECOND_CODE)
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(stashedCode()).toBe(SECOND_CODE)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(stashedCode()).toBeUndefined()
})
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(okResponse())
// user-1 approves; the redeem fails transiently, keeping the code stashed.
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
// The session changes to user-2 before the retry: user-1's approval must
// not authorize redeeming with user-2's token.
mockAuthStore.currentUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
const { seedStash, stashedCode, trigger } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
// The session swaps to user-2 while user-1's dialog is open: the stale
// approval must not redeem, and the raced auth trigger is replayed to
// re-prompt under user-2 without another navigation.
const secondUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
mockAuthStore.currentUser = secondUser
await flushRedemption()
approve(true)
await flushRedemption()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeds', () => okResponse()],
['fails terminally', () => new Response(null, { status: 404 })]
] as const)(
'processes a newer code stashed mid-flight after the older redemption %s',
async ([_label, firstResponse]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
let resolveFirstFetch!: (response: Response) => void
mockFetch.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFirstFetch = resolve
})
)
await trigger()
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
// A second code arrives while the first redemption is in flight; it
// must survive the first's settlement and be processed right after.
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
resolveFirstFetch(firstResponse())
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('coalesces concurrent triggers into one dialog and one request', async () => {
const { router, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await router.push('/burst-1')
await router.push('/burst-2')
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
approve(true)
await flushRedemption()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,263 +0,0 @@
import { watch } from 'vue'
import type { Router } from 'vue-router'
import { t } from '@/i18n'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
// backend stays the authority on exact code length.
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
// Statuses that mean the desktop app must start a fresh sign-in, so the code
// is dropped. 401 stays transient: the session may still be settling.
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
// One delayed in-page retry, so an approved sign-in always reaches a success
// or failure toast without ever looping within a page load.
const MAX_REDEEM_ATTEMPTS = 2
const RETRY_DELAY_MS = 5_000
// Abort the redeem request if the backend hangs; treated as transient.
const REDEEM_TIMEOUT_MS = 10_000
interface CodeRedemptionState {
attempts: number
approvedUserUid: string | null
settled: boolean
forceTokenRefresh: boolean
}
// Keyed by code so a different code arriving later gets its own approval and
// attempt budget, while retries of the same code reuse both.
const codeStates = new Map<string, CodeRedemptionState>()
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
// more pass instead of being dropped.
let draining = false
let retriggerRequested = false
let authWatcherInstalled = false
function getCodeState(code: string): CodeRedemptionState {
const existing = codeStates.get(code)
if (existing) return existing
const fresh = {
attempts: 0,
approvedUserUid: null,
settled: false,
forceTokenRefresh: false
}
codeStates.set(code, fresh)
return fresh
}
// A newer code can be stashed while an older one is mid-redemption; settling
// the older one must not wipe it.
function clearStashIfHolds(code: string): void {
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
clearPreservedQuery(NAMESPACE)
}
}
function settle(code: string, state: CodeRedemptionState): void {
state.settled = true
clearStashIfHolds(code)
}
function handleTransientFailure(
code: string,
state: CodeRedemptionState,
reason: string
): void {
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
// attempts only increments, so this branch runs at most once per code
// and cannot stack retry timers.
setTimeout(() => {
void redeemPendingDesktopLoginCode()
}, RETRY_DELAY_MS)
return
}
// Budget spent: drop the code and tell the user instead of failing silently.
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.failedSummary'),
detail: t('desktopLogin.failedDetail'),
life: 6000
})
}
// Explicit approval defeats device-code phishing: a lured click on a leaked
// link must not bind the victim's session to an attacker's desktop app.
// Approval is per code *and* account.
async function confirmRedemption(
state: CodeRedemptionState,
uid: string
): Promise<boolean> {
if (state.approvedUserUid === uid) return true
const confirmed = await useDialogService().confirm({
title: t('desktopLogin.confirmSummary'),
message: t('desktopLogin.confirmMessage')
})
if (confirmed !== true) return false
state.approvedUserUid = uid
return true
}
async function redeemCode(code: string): Promise<void> {
const state = getCodeState(code)
if (state.settled) {
// A later navigation can re-capture an already-settled code; drop it.
clearStashIfHolds(code)
return
}
// No session yet (e.g. code captured on the login page): keep the stash and
// let a post-login trigger redeem it.
const user = useAuthStore().currentUser
if (!user) return
if (!(await confirmRedemption(state, user.uid))) {
// Declined/dismissed: drop the code without an error.
settle(code, state)
return
}
// Approval binds the code to one account: if the session changed while the
// dialog was open, keep the code stashed and let the (replayed) auth-change
// trigger re-prompt under the now-current account.
const approvedUser = useAuthStore().currentUser
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
state.attempts++
// Token comes straight from the Firebase user: authStore.getIdToken()
// surfaces failures through a modal dialog this background flow must avoid.
let idToken: string
try {
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
} catch {
handleTransientFailure(code, state, 'could not get id token')
return
}
let response: Response
try {
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json'
},
// TODO(@comfyorg/ingest-types): type the payload with the generated
// request type once the desktop-login-codes openapi addition propagates.
body: JSON.stringify({ code }),
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
})
} catch (error) {
handleTransientFailure(
code,
state,
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: 'network error'
)
return
}
if (response.ok) {
settle(code, state)
useToastStore().add({
severity: 'success',
summary: t('desktopLogin.successSummary'),
detail: t('desktopLogin.successDetail'),
life: 4000
})
return
}
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.expiredSummary'),
detail: t('desktopLogin.expiredDetail'),
life: 6000
})
return
}
// A 401 usually means a stale cached id token; mint a fresh one on retry.
if (response.status === 401) state.forceTokenRefresh = true
handleTransientFailure(code, state, `status ${response.status}`)
}
async function redeemPendingDesktopLoginCode(): Promise<void> {
// Never rejects: the triggers fire-and-forget this.
if (draining) {
retriggerRequested = true
return
}
draining = true
try {
do {
retriggerRequested = false
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
if (!code) continue
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
clearPreservedQuery(NAMESPACE)
continue
}
await redeemCode(code)
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
retriggerRequested = true
} while (retriggerRequested)
} catch (error) {
console.error('[DesktopLoginRedemption] Redemption failed:', error)
} finally {
draining = false
}
}
function installAuthWatcherOnce(): void {
if (authWatcherInstalled) return
authWatcherInstalled = true
// A session can appear without a navigation (e.g. dialog-based sign-in).
// Installed lazily because pinia is not active when router.ts evaluates.
watch(
() => useAuthStore().currentUser,
() => {
void redeemPendingDesktopLoginCode()
}
)
}
/**
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
*
* The desktop app opens the browser with an opaque one-time code and polls
* the cloud backend; redeeming the code from a signed-in browser session,
* with the user's approval, releases a one-time custom token to that poll
* and signs the desktop app in. The preserved-query tracker (configured in
* router.ts) strips the code from the URL at capture time, so the stash is
* the only place it lives.
*/
export function installDesktopLoginRedemption(router: Router): void {
router.afterEach(() => {
installAuthWatcherOnce()
void redeemPendingDesktopLoginCode()
})
}

View File

@@ -94,7 +94,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-survey',
component: () =>
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true, hideHero: true }
meta: { requiresAuth: true }
},
{
path: 'oauth/consent',
@@ -106,7 +106,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
name: 'cloud-user-check',
component: () =>
import('@/platform/cloud/onboarding/UserCheckView.vue'),
meta: { requiresAuth: true, hideHero: true }
meta: { requiresAuth: true }
},
{
path: 'sorry-contact-support',

View File

@@ -1,176 +0,0 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurveyField } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
const renderField = (
field: OnboardingSurveyField,
props: {
modelValue?: string | string[]
otherValue?: string
errorMessage?: string
} = {},
locale = 'en'
) =>
render(DynamicSurveyField, {
global: {
plugins: [
createI18n({ legacy: false, locale, messages: { en: enMessages } })
]
},
props: { field, modelValue: undefined, ...props }
})
const optionButton = (label: string) =>
screen.getByRole('button', { name: label })
describe('DynamicSurveyField', () => {
const singleField: OnboardingSurveyField = {
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'images', label: 'Images', icon: 'icon-[lucide--image]' },
{ value: 'video', label: 'Video' }
]
}
it('renders the label and one card per option', () => {
renderField(singleField)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
})
it('emits the chosen value for a single-select card', async () => {
const user = userEvent.setup()
const { emitted } = renderField(singleField)
await user.click(screen.getByText('Images'))
expect(emitted()['update:modelValue']?.[0]).toEqual(['images'])
})
it('marks the selected single card as on (aria-pressed/state)', () => {
renderField(singleField, { modelValue: 'images' })
expect(optionButton('Images')).toHaveAttribute('data-state', 'on')
expect(optionButton('Video')).toHaveAttribute('data-state', 'off')
})
it('gives each option card a stable "<fieldId>-<value>" id', () => {
renderField(singleField)
expect(optionButton('Images')).toHaveAttribute('id', 'intent-images')
expect(optionButton('Video')).toHaveAttribute('id', 'intent-video')
})
const multiField: OnboardingSurveyField = {
id: 'making',
type: 'multi',
label: 'Pick some',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
it('emits an array for a multi-select card and reflects current selection', async () => {
const user = userEvent.setup()
const { emitted } = renderField(multiField, { modelValue: ['a'] })
expect(optionButton('Making A')).toHaveAttribute('data-state', 'on')
await user.click(screen.getByText('Making B'))
const events = emitted()['update:modelValue'] as unknown[][] | undefined
const last = events?.at(-1)?.[0]
expect(last).toEqual(expect.arrayContaining(['a', 'b']))
})
it('shows the "other" free-text input only when "other" is selected and emits it', async () => {
const user = userEvent.setup()
const otherField: OnboardingSurveyField = {
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
const { rerender, emitted } = renderField(otherField, {
modelValue: 'search'
})
expect(
screen.queryByPlaceholderText('Where did you find us?')
).not.toBeInTheDocument()
await rerender({ field: otherField, modelValue: 'other', otherValue: '' })
const input = screen.getByPlaceholderText('Where did you find us?')
await user.type(input, 'A podcast')
expect(emitted()['update:otherValue']?.length).toBeGreaterThan(0)
})
it('renders a text field and emits typed input', async () => {
const user = userEvent.setup()
const textField: OnboardingSurveyField = {
id: 'note',
type: 'text',
label: 'Anything else?',
placeholder: 'Your note'
}
const { emitted } = renderField(textField)
await user.type(screen.getByPlaceholderText('Your note'), 'Hi')
expect(emitted()['update:modelValue']?.length).toBeGreaterThan(0)
})
it('resolves labels via labelKey, locale map, and falls back to the value', () => {
renderField(
{
id: 'q',
type: 'single',
labelKey: 'cloudSurvey_steps_intent',
options: [
{ value: 'x', label: { en: 'Ex', ko: '엑스' } },
{ value: 'raw' } // no label → falls back to the value
]
},
{}
)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Ex')).toBeInTheDocument()
expect(screen.getByText('raw')).toBeInTheDocument()
})
it('resolves a field label from a locale map when no labelKey is set', () => {
renderField({
id: 'q',
type: 'single',
label: { en: 'Server question', ko: '서버 질문' },
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('Server question')).toBeVisible()
})
it('falls back to the field id when neither labelKey nor label resolves', () => {
renderField({
id: 'bare_field_id',
type: 'single',
options: [{ value: 'a', label: 'A' }]
})
expect(screen.getByText('bare_field_id')).toBeVisible()
})
it('renders the error message when provided', () => {
renderField(singleField, { errorMessage: 'Please choose an option.' })
expect(screen.getByText('Please choose an option.')).toBeVisible()
})
})

View File

@@ -2,72 +2,62 @@
<fieldset
v-if="field.type !== 'text'"
:aria-invalid="Boolean(errorMessage)"
class="m-0 flex flex-col gap-4 border-0 p-0"
class="flex flex-col gap-4 border-0 p-0"
>
<legend class="mb-2 block text-lg font-medium text-primary-comfy-canvas">
<legend class="mb-2 block text-lg font-medium text-base-foreground">
{{ resolvedLabel }}
</legend>
<ToggleGroup
v-if="field.type === 'single'"
:model-value="(modelValue as string) ?? ''"
type="single"
class="flex w-full flex-col gap-2"
@update:model-value="onSingleChange"
>
<ToggleGroupItem
<template v-if="field.type === 'single'">
<div
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
:value="option.value"
:class="optionCardClass"
class="flex items-center gap-3"
>
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
<RadioButton
:model-value="(modelValue as string) ?? ''"
:input-id="`${field.id}-${option.value}`"
:name="field.id"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onSingleChange"
/>
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-else
:model-value="(modelValue as string[]) ?? []"
type="multiple"
class="flex w-full flex-col gap-2"
@update:model-value="onMultiChange"
>
<ToggleGroupItem
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<template v-else>
<div
v-for="option in field.options"
:id="`${field.id}-${option.value}`"
:key="option.value"
:value="option.value"
:class="optionCardClass"
class="flex items-center gap-3"
>
<i
v-if="option.icon"
:class="
cn('size-4 shrink-0 text-primary-comfy-canvas/60', option.icon)
"
aria-hidden="true"
<Checkbox
:model-value="(modelValue as string[]) ?? []"
:input-id="`${field.id}-${option.value}`"
:value="option.value"
:dt="checkedTokens"
@update:model-value="onMultiChange"
/>
<span class="flex-1">{{ resolveOptionLabel(option) }}</span>
<i :class="checkMarkClass" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
<label
:for="`${field.id}-${option.value}`"
class="cursor-pointer text-sm"
>{{ resolveOptionLabel(option) }}</label
>
</div>
</template>
<Input
v-if="field.allowOther && field.otherFieldId && isOtherSelected"
v-if="field.allowOther && field.otherFieldId && modelValue === 'other'"
:model-value="(otherValue as string) ?? ''"
:class="inputClass"
:maxlength="OTHER_TEXT_MAX_LENGTH"
:placeholder="
$t(
`cloudOnboarding.survey.options.${field.id}.otherPlaceholder`,
$t('cloudOnboarding.survey.otherPlaceholder')
)
"
class="ml-1"
@update:model-value="onOtherChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -75,7 +65,7 @@
<div v-else class="flex flex-col gap-3">
<label
:for="controlId"
class="block text-lg font-medium text-primary-comfy-canvas"
class="block text-lg font-medium text-base-foreground"
>
{{ resolvedLabel }}
</label>
@@ -84,7 +74,6 @@
:model-value="(modelValue as string) ?? ''"
:placeholder="field.placeholder"
:aria-invalid="Boolean(errorMessage)"
:class="inputClass"
@update:model-value="onTextChange"
/>
<p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p>
@@ -92,20 +81,18 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, useId } from 'vue'
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Input from '@/components/ui/input/Input.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type {
LocalizedString,
OnboardingSurveyField,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
import { OTHER_TEXT_MAX_LENGTH } from './surveySchema'
const {
field,
modelValue,
@@ -126,31 +113,25 @@ const emit = defineEmits<{
const { t, te, locale } = useI18n()
const controlId = useId()
const optionCardClass =
'group h-auto w-full items-center justify-start gap-3 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 px-4 py-3 text-left text-sm text-primary-comfy-canvas shadow-inset-highlight transition-colors hover:bg-sand-300/20 data-[state=on]:bg-sand-300/15 data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-brand-yellow'
const checkMarkClass =
'icon-[lucide--check] size-4 shrink-0 text-brand-yellow opacity-0 group-data-[state=on]:opacity-100'
const inputClass =
'border-smoke-800/10 bg-smoke-800/10 text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/50 focus-visible:ring-inset'
const isOtherSelected = computed(() =>
Array.isArray(modelValue)
? modelValue.includes('other')
: modelValue === 'other'
)
const resolveLocalized = (value: LocalizedString): string => {
if (typeof value === 'string') return value
return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? ''
}
const resolvedLabel = computed(() => {
const checkedTokens = {
checked: {
background: 'var(--color-electric-400)',
borderColor: 'var(--color-electric-400)',
hoverBackground: 'var(--color-electric-400)',
hoverBorderColor: 'var(--color-electric-400)'
}
}
const resolvedLabel = (() => {
if (field.labelKey && te(field.labelKey)) return t(field.labelKey)
if (field.label != null) return resolveLocalized(field.label)
return field.id
})
})()
const resolveOptionLabel = (option: OnboardingSurveyOption): string => {
if (option.labelKey && te(option.labelKey)) return t(option.labelKey)
@@ -162,10 +143,13 @@ const onSingleChange = (value: unknown) => {
emit('update:modelValue', typeof value === 'string' ? value : '')
}
const onMultiChange = (value: unknown) => {
const selected = Array.isArray(value) ? value : []
if (!Array.isArray(value)) {
emit('update:modelValue', [])
return
}
emit(
'update:modelValue',
selected.filter((v): v is string => typeof v === 'string')
value.filter((v): v is string => typeof v === 'string')
)
}
const onTextChange = (value: string | number | undefined) => {

View File

@@ -1,374 +1,154 @@
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyForm from './DynamicSurveyForm.vue'
import { defaultOnboardingSurvey } from './defaultSurveySchema'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: 'Please choose an option.',
selectAtLeastOne: 'Please select at least one option.',
describeAnswer: 'Please describe your answer.'
}
}
}
}
}
})
const renderForm = (survey: OnboardingSurvey) =>
render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
global: { plugins: [PrimeVue, i18n] },
props: { survey }
})
const clickOption = (user: ReturnType<typeof userEvent.setup>, label: string) =>
user.click(screen.getByText(label))
const firstSubmitPayload = (
emitted: Record<string, unknown[]>
): Record<string, unknown> | undefined =>
(emitted.submit?.[0] as [Record<string, unknown>] | undefined)?.[0]
const twoStepSurvey: OnboardingSurvey = {
version: 1,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'intent',
id: 'usage',
type: 'single',
label: 'What do you want to make?',
label: 'How do you plan to use ComfyUI?',
required: true,
options: [
{ value: 'personal', label: 'Personal use' },
{ value: 'work', label: 'Work' }
]
},
{
id: 'intent',
type: 'multi',
label: 'What do you want to create with ComfyUI?',
required: true,
options: [
{ value: 'images', label: 'Images' },
{ value: 'video', label: 'Video' }
{ value: 'videos', label: 'Videos' }
]
},
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [
{ value: 'a', label: 'Making A' },
{ value: 'b', label: 'Making B' }
]
}
]
}
const branchedSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'intent',
type: 'single',
label: 'What do you want to make?',
required: true,
options: [
{ value: 'workflows', label: 'Workflows' },
{ value: 'images', label: 'Images' }
]
},
{
id: 'focus',
type: 'single',
label: 'What are you building?',
required: true,
showWhen: { field: 'intent', equals: 'workflows' },
options: [{ value: 'custom_nodes', label: 'Custom nodes' }]
}
]
}
describe('DynamicSurveyForm', () => {
it('renders the real default schema (v3) with its first question and options', () => {
expect(defaultOnboardingSurvey.version).toBe(3)
const firstField = defaultOnboardingSurvey.fields[0]!
renderForm(defaultOnboardingSurvey)
expect(screen.getByText('What do you want to make?')).toBeVisible()
expect(screen.getByText('Images')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(
firstField.options!.length
)
})
it('auto-advances when a single-select option is chosen', async () => {
const user = userEvent.setup()
it('renders the intro text and the first field options', () => {
renderForm(twoStepSurvey)
// No Next click — choosing the card advances the wizard.
await clickOption(user, 'Images')
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
screen.getByText('Help us tailor your ComfyUI experience.')
).toBeInTheDocument()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
expect(screen.getByLabelText('Personal use')).toBeInTheDocument()
expect(screen.getByLabelText('Work')).toBeInTheDocument()
})
it('does not auto-advance a multi-select step; Submit gates on a choice', async () => {
it('disables Next until the user selects an option, then advances', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeDisabled()
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await user.click(screen.getByLabelText('Personal use'))
expect(next).toBeEnabled()
await clickOption(user, 'Making A')
// Still on the multi step (no auto-advance), now submittable.
expect(screen.getByText('Pick everything that applies')).toBeVisible()
await waitFor(() => expect(submit).toBeEnabled())
await user.click(next)
await flushPromises()
expect(
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
expect(screen.getByLabelText('Images')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
})
it('navigates back to the previous step', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
await user.click(screen.getByLabelText('Personal use'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
expect(
await screen.findByText('Pick everything that applies')
screen.getByText('What do you want to create with ComfyUI?')
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(await screen.findByText('What do you want to make?')).toBeVisible()
await flushPromises()
expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible()
})
it('offers Next on an already-answered single-select reached via Back', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await clickOption(user, 'Images')
await screen.findByText('Pick everything that applies')
await user.click(screen.getByRole('button', { name: 'Back' }))
const next = await screen.findByRole('button', { name: 'Next' })
await user.click(next)
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
})
it('reveals a branched follow-up step from the answer and submits it', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
await clickOption(user, 'Workflows')
expect(await screen.findByText('What are you building?')).toBeVisible()
await clickOption(user, 'Custom nodes')
await user.click(await screen.findByRole('button', { name: 'Submit' }))
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'workflows',
focus: 'custom_nodes'
})
)
})
it('hides the branched step when the answer does not match', async () => {
const user = userEvent.setup()
const { emitted } = renderForm(branchedSurvey)
// 'images' is the last visible step (focus hidden) → Submit, no branch.
await clickOption(user, 'Images')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(screen.queryByText('What are you building?')).not.toBeInTheDocument()
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({
intent: 'images',
focus: ''
})
)
})
it('requires the "other" free-text before submitting, then submits it', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
it('resolves option and field labels via labelKey when provided', () => {
const localizedI18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: 'Help us tailor your ComfyUI experience.',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
},
survey_label: 'Localized question?',
survey_a: 'Localized A',
survey_b: 'Localized B'
}
]
}
const { emitted } = renderForm(otherSurvey)
// Selecting 'other' must NOT auto-advance — the text box is required.
await clickOption(user, 'Somewhere else')
const submit = await screen.findByRole('button', { name: 'Submit' })
expect(submit).toBeDisabled()
await user.type(
await screen.findByPlaceholderText('Where did you find us?'),
'A newsletter'
)
await waitFor(() => expect(submit).toBeEnabled())
await user.click(submit)
await waitFor(() =>
expect(firstSubmitPayload(emitted())).toEqual({ source: 'A newsletter' })
)
})
it('surfaces the free-text error once "other" text is touched then cleared', async () => {
const user = userEvent.setup()
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
label: 'How did you find us?',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', label: 'Web search' },
{ value: 'other', label: 'Somewhere else' }
]
}
]
}
renderForm(otherSurvey)
await clickOption(user, 'Somewhere else')
const input = await screen.findByPlaceholderText('Where did you find us?')
// Type then clear → the free-text field is touched but empty, so its
// required error surfaces.
await user.type(input, 'x')
await user.clear(input)
expect(
await screen.findByText('Please describe your answer.')
).toBeVisible()
})
it('shows a required-field error only after the user interacts, not before', async () => {
const user = userEvent.setup()
renderForm({
version: 1,
fields: [
{
id: 'making',
type: 'multi',
label: 'Pick everything that applies',
required: true,
options: [{ value: 'a', label: 'Making A' }]
}
]
}
})
// No error on first render (field untouched).
expect(
screen.queryByText('Please select at least one option.')
).not.toBeInTheDocument()
// Select then clear → field is touched but empty → error surfaces.
await user.click(screen.getByText('Making A'))
await user.click(screen.getByText('Making A'))
expect(
await screen.findByText('Please select at least one option.')
).toBeVisible()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
renderForm({
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
]
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
expect(await screen.findByText('Required question?')).toBeVisible()
})
it('resets to the first step when the survey prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
},
props: { survey: twoStepSurvey }
})
await clickOption(user, 'Images')
expect(
await screen.findByText('Pick everything that applies')
).toBeVisible()
await rerender({ survey: branchedSurvey })
// Back on step 0 of the new survey (no Back button on the first step).
expect(await screen.findByText('What do you want to make?')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Back' })
).not.toBeInTheDocument()
})
it('renders server-supplied label translations and falls back to English', () => {
render(DynamicSurveyForm, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: { en: enMessages, ko: { g: { next: '다음' } } }
})
]
},
global: { plugins: [PrimeVue, localizedI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'intent',
id: 'q',
type: 'single',
label: { en: 'What will you make?', ko: '무엇을 만들 건가요?' },
labelKey: 'survey_label',
required: true,
options: [
// ko provided → localized; ko missing → English fallback
{ value: 'images', label: { en: 'Images', ko: '이미지' } },
{ value: 'video', label: { en: 'Video' } }
{ value: 'a', labelKey: 'survey_a' },
{ value: 'b', labelKey: 'survey_b' }
]
}
]
@@ -376,8 +156,165 @@ describe('DynamicSurveyForm', () => {
}
})
expect(screen.getByText('무엇을 만들 건가요?')).toBeVisible()
expect(screen.getByText('이미지')).toBeInTheDocument()
expect(screen.getByText('Video')).toBeInTheDocument()
expect(screen.getByText('Localized question?')).toBeVisible()
expect(screen.getByLabelText('Localized A')).toBeInTheDocument()
expect(screen.getByLabelText('Localized B')).toBeInTheDocument()
})
it('renders server-supplied translations from a label locale map', () => {
const koreanI18n = createI18n({
legacy: false,
locale: 'ko',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
ko: { g: { back: '뒤로', next: '다음', submit: '제출' } }
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, koreanI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'usage',
type: 'single',
label: {
en: 'How will you use it?',
ko: '어떻게 사용하시겠어요?'
},
required: true,
options: [
{
value: 'personal',
label: { en: 'Personal use', ko: '개인 용도' }
},
{ value: 'work', label: { en: 'Work', ko: '업무' } }
]
}
]
}
}
})
expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible()
expect(screen.getByLabelText('개인 용도')).toBeInTheDocument()
expect(screen.getByLabelText('업무')).toBeInTheDocument()
})
it('falls back to English when current locale missing from label map', () => {
const fallbackI18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: {
g: { back: 'Back', next: 'Next', submit: 'Submit' },
cloudOnboarding: {
survey: {
intro: '',
errors: {
chooseAnOption: '',
selectAtLeastOne: '',
describeAnswer: ''
}
}
}
},
fr: {}
}
})
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, fallbackI18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q',
type: 'single',
label: { en: 'English question', ko: '한국어' },
required: true,
options: [
{ value: 'a', label: { en: 'English A', ko: '한국어 A' } }
]
}
]
}
}
})
// fr is not in the map → falls back to en
expect(screen.getByText('English question')).toBeVisible()
expect(screen.getByLabelText('English A')).toBeInTheDocument()
})
it('allows advancing past an optional field while still empty', async () => {
const user = userEvent.setup()
render(DynamicSurveyForm, {
global: { plugins: [PrimeVue, i18n] },
props: {
survey: {
version: 1,
fields: [
{
id: 'q1',
type: 'single',
label: 'Optional question?',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' }
]
// no required: true — should be skippable
},
{
id: 'q2',
type: 'single',
label: 'Required question?',
required: true,
options: [{ value: 'c', label: 'C' }]
}
]
}
}
})
const next = screen.getByRole('button', { name: 'Next' })
expect(next).toBeEnabled()
await user.click(next)
await flushPromises()
expect(screen.getByText('Required question?')).toBeVisible()
})
it('enables Submit only after the multi-select field has at least one choice', async () => {
const user = userEvent.setup()
renderForm(twoStepSurvey)
await user.click(screen.getByLabelText('Work'))
await user.click(screen.getByRole('button', { name: 'Next' }))
await flushPromises()
const submitBtn = screen.getByRole('button', { name: 'Submit' })
expect(submitBtn).toBeDisabled()
await user.click(screen.getByRole('checkbox', { name: /Images/i }))
await flushPromises()
expect(submitBtn).toBeEnabled()
})
})

View File

@@ -1,118 +1,109 @@
<template>
<form class="flex w-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted-foreground">
<form class="flex size-full flex-col" @submit.prevent="onSubmit">
<p v-if="introText" class="mb-4 text-sm text-muted">
{{ introText }}
</p>
<div
class="mb-8 h-1.5 w-full overflow-hidden rounded-full bg-primary-comfy-canvas/10"
class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background"
>
<div
class="h-full bg-brand-yellow transition-[width] duration-300 ease-out"
class="h-full bg-electric-400 transition-[width] duration-300 ease-out"
:style="{ width: `${progressPercent}%` }"
/>
</div>
<div
class="overflow-hidden transition-[height] duration-300 ease-out"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
leave-active-class="absolute inset-x-0 top-0 transition-opacity duration-300 ease-out"
leave-to-class="opacity-0"
>
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-col gap-4"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="currentError"
@update:model-value="
(value) => void onFieldChange(currentField.id, value)
"
@update:other-value="
(value) =>
currentField.otherFieldId &&
void onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</Transition>
<div class="flex flex-1 flex-col overflow-hidden">
<div
v-if="currentField"
:key="currentField.id"
class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1"
>
<DynamicSurveyField
:field="currentField"
:model-value="values[currentField.id]"
:other-value="
currentField.otherFieldId
? (values[currentField.otherFieldId] as string)
: undefined
"
:error-message="
errors[currentField.id] ??
(currentField.otherFieldId
? errors[currentField.otherFieldId]
: undefined)
"
@update:model-value="(value) => onFieldChange(currentField.id, value)"
@update:other-value="
(value) =>
currentField.otherFieldId &&
onFieldChange(currentField.otherFieldId, value)
"
/>
</div>
</div>
<div
v-if="!isFirst || showNext || isLast"
class="mt-8 flex items-center justify-between gap-4"
>
<div class="flex gap-6 pt-4">
<Button
v-if="!isFirst"
type="button"
variant="link"
size="lg"
class="px-0 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
variant="secondary"
class="h-10 flex-1 text-white"
@click="goPrevious"
>
<i class="icon-[lucide--chevron-left] size-4" aria-hidden="true" />
{{ $t('g.back') }}
</Button>
<span v-else />
<span v-else class="flex-1" />
<Button
v-if="showNext"
v-if="!isLast"
type="button"
size="lg"
:disabled="!isCurrentValid"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
@click="goNext"
>
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" aria-hidden="true" />
</Button>
<Button
v-else-if="isLast"
v-else
type="submit"
size="lg"
:disabled="!isCurrentValid || isSubmitting"
:loading="isSubmitting"
class="bg-brand-yellow text-primary-comfy-ink hover:bg-brand-yellow/85 disabled:bg-smoke-800/10 disabled:text-primary-comfy-canvas/40 disabled:opacity-100"
:class="
cn(
'h-10 flex-1 border-none',
isCurrentValid && !isSubmitting
? 'bg-electric-400 text-black hover:bg-electric-400/85'
: 'bg-zinc-800 text-zinc-500'
)
"
>
{{ $t('g.submit') }}
</Button>
<span v-else />
</div>
</form>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { cn } from '@comfyorg/tailwind-utils'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
OnboardingSurvey,
OnboardingSurveyField
} from '@/platform/remoteConfig/types'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import DynamicSurveyField from './DynamicSurveyField.vue'
import {
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
isOtherValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -156,8 +147,6 @@ watch(
liveValues.value = { ...fresh }
resetForm({ values: fresh })
stepIndex.value = 0
touched.value = new Set()
isAdvancing.value = false
}
)
@@ -165,43 +154,11 @@ const visible = computed(() =>
visibleFields(preparedSurvey.value, values as SurveyValues)
)
const stepIndex = ref(0)
const touched = ref(new Set<string>())
const isAdvancing = ref(false)
const questionContent = useTemplateRef<HTMLElement>('questionContent')
const { height: contentHeight } = useElementSize(questionContent)
const animatedHeightStyle = computed(() =>
contentHeight.value ? { height: `${contentHeight.value}px` } : {}
)
const currentField = computed(() => visible.value[stepIndex.value])
const isFirst = computed(() => stepIndex.value === 0)
const isLast = computed(() => stepIndex.value === visible.value.length - 1)
const showNext = computed(() => {
if (isLast.value || isAdvancing.value) return false
const field = currentField.value
if (!field) return false
if (field.type !== 'single') return true
return !(field.required && !hasNonEmptyValue(values[field.id]))
})
const currentError = computed(() => {
const field = currentField.value
if (!field) return undefined
if (touched.value.has(field.id) && errors.value[field.id]) {
return errors.value[field.id]
}
if (
field.otherFieldId &&
touched.value.has(field.otherFieldId) &&
errors.value[field.otherFieldId]
) {
return errors.value[field.otherFieldId]
}
return undefined
})
const totalSteps = computed(() => Math.max(visible.value.length, 1))
const progressPercent = computed(() =>
Math.max(
@@ -215,41 +172,26 @@ const isCurrentValid = computed(() => {
if (!field) return false
const value = values[field.id]
if (!hasNonEmptyValue(value)) return !field.required
const isEmpty =
field.type === 'multi'
? !Array.isArray(value) || value.length === 0
: typeof value !== 'string' || value.length === 0
if (field.allowOther && field.otherFieldId && isOtherValue(value)) {
if (isEmpty) return !field.required
if (field.allowOther && field.otherFieldId && value === 'other') {
const other = values[field.otherFieldId]
return typeof other === 'string' && other.trim().length > 0
}
return true
})
const isAutoAdvanceValue = (field: OnboardingSurveyField, value: unknown) =>
field.type === 'single' &&
typeof value === 'string' &&
value !== '' &&
value !== 'other'
const markTouched = (id: string) => {
touched.value = new Set(touched.value).add(id)
}
const onFieldChange = async (id: string, value: string | string[]) => {
if (isAdvancing.value) return
markTouched(id)
const onFieldChange = (id: string, value: string | string[]) => {
setFieldValue(id, value)
liveValues.value = { ...liveValues.value, [id]: value }
if (stepIndex.value > visible.value.length - 1) {
stepIndex.value = Math.max(0, visible.value.length - 1)
}
const field = currentField.value
if (field?.id === id && isAutoAdvanceValue(field, value)) {
isAdvancing.value = true
await nextTick()
goNext()
isAdvancing.value = false
}
}
const goNext = () => {
@@ -260,11 +202,6 @@ const goPrevious = () => {
}
const onSubmit = async () => {
const field = currentField.value
if (field) {
markTouched(field.id)
if (field.otherFieldId) markTouched(field.otherFieldId)
}
const result = await validate()
if (!result.valid) return
emit(

View File

@@ -1,61 +1,55 @@
import type {
OnboardingSurvey,
OnboardingSurveyOption
} from '@/platform/remoteConfig/types'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
const optionsFor = (
fieldId: string,
values: string[],
icons: Record<string, string> = {}
): OnboardingSurveyOption[] =>
values: string[]
): { value: string; labelKey: string }[] =>
values.map((value) => ({
value,
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`,
...(icons[value] ? { icon: icons[value] } : {})
labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}`
}))
export const defaultOnboardingSurvey: OnboardingSurvey = {
version: 3,
version: 2,
introKey: 'cloudOnboarding.survey.intro',
fields: [
{
id: 'intent',
id: 'usage',
type: 'single',
labelKey: 'cloudSurvey_steps_usage',
required: true,
options: optionsFor('usage', ['personal', 'work', 'education'])
},
{
id: 'familiarity',
type: 'single',
labelKey: 'cloudSurvey_steps_familiarity',
required: true,
options: optionsFor('familiarity', [
'new',
'starting',
'basics',
'advanced',
'expert'
])
},
{
id: 'intent',
type: 'multi',
labelKey: 'cloudSurvey_steps_intent',
required: true,
allowOther: true,
otherFieldId: 'intentOther',
options: optionsFor(
'intent',
['images', 'video', 'workflows', 'apps_api', 'exploring', 'other'],
{
images: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
workflows: 'icon-[lucide--workflow]',
apps_api: 'icon-[lucide--blocks]',
exploring: 'icon-[lucide--compass]',
other: 'icon-[lucide--pencil]'
}
)
},
{
id: 'experience',
type: 'single',
labelKey: 'cloudSurvey_steps_experience',
required: true,
options: optionsFor('experience', ['new', 'some', 'pro'], {
new: 'icon-[lucide--sprout]',
some: 'icon-[lucide--map]',
pro: 'icon-[lucide--rocket]'
})
},
{
id: 'focus',
type: 'single',
labelKey: 'cloudSurvey_steps_focus',
required: true,
showWhen: { field: 'intent', equals: ['workflows', 'apps_api'] },
options: optionsFor('focus', ['custom_nodes', 'pipelines', 'products'])
randomize: true,
options: optionsFor('intent', [
'workflows',
'custom_nodes',
'videos',
'images',
'3d_game',
'audio',
'apps',
'api',
'not_sure'
])
},
{
id: 'source',
@@ -63,31 +57,19 @@ export const defaultOnboardingSurvey: OnboardingSurvey = {
labelKey: 'cloudSurvey_steps_source',
required: true,
randomize: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: optionsFor('source', [
'social',
'friend',
'search',
'community',
'other'
])
},
{
id: 'source_social',
type: 'single',
labelKey: 'cloudSurvey_steps_source_social',
required: true,
randomize: true,
showWhen: { field: 'source', equals: 'social' },
options: optionsFor('source_social', [
'youtube',
'reddit',
'twitter',
'instagram',
'tiktok',
'linkedin',
'discord'
'friend',
'search',
'newsletter',
'conference',
'discord',
'github',
'other'
])
}
]

View File

@@ -2,13 +2,10 @@ import { describe, expect, it } from 'vitest'
import type { OnboardingSurvey } from '@/platform/remoteConfig/types'
import { defaultOnboardingSurvey } from './defaultSurveySchema'
import {
OTHER_TEXT_MAX_LENGTH,
buildInitialValues,
buildSubmissionPayload,
buildZodSchema,
hasNonEmptyValue,
prepareSurvey,
visibleFields
} from './surveySchema'
@@ -249,179 +246,3 @@ describe('prepareSurvey', () => {
expect(values.slice(0, -2).sort()).toEqual(['a', 'b'])
})
})
describe('defaultOnboardingSurvey branching', () => {
const idsFor = (values: Record<string, string | string[]>) =>
visibleFields(defaultOnboardingSurvey, values).map((f) => f.id)
it('asks only the core steps when no branch condition is met', () => {
expect(idsFor({ intent: 'images', source: 'friend' })).toEqual([
'intent',
'experience',
'source'
])
})
it('asks every step when both branches are active', () => {
expect(idsFor({ intent: 'workflows', source: 'social' })).toEqual([
'intent',
'experience',
'focus',
'source',
'source_social'
])
})
it('asks focus only for builder intents (workflows / apps_api)', () => {
expect(idsFor({ intent: 'workflows' })).toContain('focus')
expect(idsFor({ intent: 'apps_api' })).toContain('focus')
expect(idsFor({ intent: 'images' })).not.toContain('focus')
expect(idsFor({ intent: 'exploring' })).not.toContain('focus')
})
it('asks source_social only when source is social', () => {
expect(idsFor({ source: 'social' })).toContain('source_social')
expect(idsFor({ source: 'friend' })).not.toContain('source_social')
})
it('zeroes hidden branch fields in the submission payload', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'images',
experience: 'new',
source: 'friend'
})
expect(payload).toMatchObject({
intent: 'images',
experience: 'new',
source: 'friend',
focus: '',
source_social: ''
})
})
it('prefers free-text over the "other" sentinel for intent and source', () => {
const payload = buildSubmissionPayload(defaultOnboardingSurvey, {
intent: 'other',
intentOther: ' Comics ',
experience: 'pro',
source: 'other',
sourceOther: 'A podcast'
})
expect(payload.intent).toBe('Comics')
expect(payload.source).toBe('A podcast')
})
})
describe('hasNonEmptyValue', () => {
const cases: [string | string[] | undefined, boolean][] = [
[undefined, false],
['', false],
[[], false],
['a', true],
[['a'], true],
[['a', 'b'], true]
]
it.for(cases)('treats %o as non-empty=%o', ([value, expected]) => {
expect(hasNonEmptyValue(value)).toBe(expected)
})
})
describe('multi-select allowOther', () => {
const multiOtherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'making',
type: 'multi',
required: true,
allowOther: true,
otherFieldId: 'makingOther',
options: [
{ value: 'a', labelKey: 'a' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('requires the free-text when a multi field includes "other"', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ''
})
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: '' }).success
).toBe(false)
expect(
schema.safeParse({ making: ['a', 'other'], makingOther: 'Comics' })
.success
).toBe(true)
})
it('does not require the free-text when "other" is not among the choices', () => {
const schema = buildZodSchema(multiOtherSurvey, {
making: ['a'],
makingOther: ''
})
expect(schema.safeParse({ making: ['a'], makingOther: '' }).success).toBe(
true
)
})
it('keeps the array and surfaces the trimmed free-text separately', () => {
const payload = buildSubmissionPayload(multiOtherSurvey, {
making: ['a', 'other'],
makingOther: ' Comics '
})
expect(payload.making).toEqual(['a', 'other'])
expect(payload.makingOther).toBe('Comics')
})
})
describe('other free-text validation', () => {
const otherSurvey: OnboardingSurvey = {
version: 1,
fields: [
{
id: 'source',
type: 'single',
required: true,
allowOther: true,
otherFieldId: 'sourceOther',
options: [
{ value: 'search', labelKey: 'search' },
{ value: 'other', labelKey: 'other' }
]
}
]
}
it('rejects a whitespace-only "other" answer', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: ' '
})
expect(
schema.safeParse({ source: 'other', sourceOther: ' ' }).success
).toBe(false)
})
it('rejects an "other" answer longer than the max length', () => {
const schema = buildZodSchema(otherSurvey, {
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
})
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH + 1)
}).success
).toBe(false)
expect(
schema.safeParse({
source: 'other',
sourceOther: 'x'.repeat(OTHER_TEXT_MAX_LENGTH)
}).success
).toBe(true)
})
})

View File

@@ -9,19 +9,12 @@ import type {
export type SurveyValues = Record<string, string | string[] | undefined>
export const OTHER_TEXT_MAX_LENGTH = 200
export const hasNonEmptyValue = (
current: string | string[] | undefined
): boolean => {
const hasNonEmptyValue = (current: string | string[] | undefined): boolean => {
if (current === undefined || current === '') return false
if (Array.isArray(current)) return current.length > 0
return true
}
export const isOtherValue = (current: string | string[] | undefined): boolean =>
Array.isArray(current) ? current.includes('other') : current === 'other'
const conditionMatches = (
condition: OnboardingSurveyFieldCondition | undefined,
values: SurveyValues
@@ -61,7 +54,7 @@ export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({
fields: survey.fields.map(randomizeOptions)
})
type Translator = (key: string, named?: Record<string, unknown>) => string
type Translator = (key: string) => string
const identityTranslator: Translator = (key) => key
@@ -94,19 +87,11 @@ export const buildZodSchema = (
if (
field.allowOther &&
field.otherFieldId &&
isOtherValue(values[field.id])
values[field.id] === 'other'
) {
shape[field.otherFieldId] = z
.string()
.trim()
.min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
.max(OTHER_TEXT_MAX_LENGTH, {
message: t('cloudOnboarding.survey.errors.answerTooLong', {
max: OTHER_TEXT_MAX_LENGTH
})
})
shape[field.otherFieldId] = z.string().min(1, {
message: t('cloudOnboarding.survey.errors.describeAnswer')
})
} else if (field.otherFieldId) {
shape[field.otherFieldId] = z.string().optional()
}
@@ -135,23 +120,17 @@ export const buildSubmissionPayload = (
continue
}
const value = values[field.id]
const otherFieldId = field.otherFieldId
const otherRaw = otherFieldId ? values[otherFieldId] : undefined
const otherText =
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
if (
field.allowOther &&
otherFieldId &&
isOtherValue(value) &&
field.otherFieldId &&
value === 'other' &&
typeof otherRaw === 'string'
? otherRaw.trim()
: undefined
if (otherText !== undefined && field.type !== 'multi') {
payload[field.id] = otherText || 'other'
) {
const other = otherRaw.trim()
payload[field.id] = other || 'other'
} else {
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
if (otherText !== undefined && otherFieldId) {
payload[otherFieldId] = otherText
}
}
}
return payload

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
const DOT_COLORS = [
'bg-destructive-background',
'bg-warning-background',
'bg-success-background'
]
const { showSubscriptionDialog } = useBillingContext()
const { t } = useI18n()
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
useFreeTierQuota()
const dotColor = computed(() => {
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
return DOT_COLORS[
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
]
})
const label = computed(() =>
available.value === 0
? t('actionbar.freeTierRunsExhausted')
: t('actionbar.freeTierRuns', {
available: available.value,
MAX_AVAILABLE: maxAvailable.value
})
)
</script>
<template>
<div
v-if="quotaEnabled"
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
data-testid="free-tier-quota"
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
>
<div
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">
<div class="flex gap-2" :aria-label="label" role="img">
<div
v-for="index in maxAvailable"
:key="index"
:class="
cn(
'size-1.5 rounded-full',
index > available ? 'bg-secondary-background-selected' : dotColor
)
"
/>
</div>
<div v-text="label" />
</div>
</div>
</template>

View File

@@ -1,45 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const useFreeTierQuota = createSharedComposable(function () {
const { flags } = useFeatureFlags()
const creditsBadges = useCreditsBadgesInGraph()
const available = ref(0)
const maxAvailable = ref(0)
watch(
() => remoteConfig.value.free_tier_balance?.remaining,
(val) => (available.value = val ?? 0),
{ immediate: true }
)
watch(
() => remoteConfig.value.free_tier_balance?.allowance,
(val) => (maxAvailable.value = val ?? 0),
{ immediate: true }
)
const quotaEnabled = computed(
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
)
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
const freeTierExecutionPermitted = computed(
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
)
function trackRun() {
if (available.value > 0) available.value--
}
return {
available,
freeTierExecutionPermitted,
hasInvalidNodes,
maxAvailable,
quotaEnabled,
trackRun
}
})

View File

@@ -79,17 +79,6 @@ vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
})
}))
function expectRekaPricingDialogProps(
dialogComponentProps: Record<string, unknown>
) {
expect(dialogComponentProps).toMatchObject({
renderer: 'reka',
size: 'full'
})
expect(dialogComponentProps).not.toHaveProperty('style')
expect(dialogComponentProps).not.toHaveProperty('pt')
}
describe('useSubscriptionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -147,7 +136,11 @@ describe('useSubscriptionDialog', () => {
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
expectRekaPricingDialogProps(dialogComponentProps)
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
@@ -181,8 +174,6 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).toHaveProperty('onChooseTeam')
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
expectRekaPricingDialogProps(dialogComponentProps)
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
@@ -203,21 +194,6 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the legacy workspace pricing dialog via Reka contentClass', () => {
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
expect(dialogComponentProps).toMatchObject({
modal: false
})
expectRekaPricingDialogProps(dialogComponentProps)
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false

View File

@@ -80,12 +80,19 @@ export const useSubscriptionDialog = () => {
trackModalOpened(options?.reason)
const legacyPricingDialogProps = {
renderer: 'reka',
size: 'full',
contentClass:
'sm:max-w-7xl max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
} as const
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
root: {
class: 'rounded-2xl bg-transparent h-full'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
}
}
}
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) for workspaces on the consolidated billing flow. Replaces
@@ -113,10 +120,7 @@ export const useSubscriptionDialog = () => {
// The legacy table hosts a PrimeVue Popover teleported to body; Reka
// modal mode traps focus and disables body pointer-events, making it
// unclickable. The unified table has no such overlay.
dialogComponentProps: {
...legacyPricingDialogProps,
modal: false
}
dialogComponentProps: { ...dialogComponentProps, modal: false }
})
return
}
@@ -140,7 +144,7 @@ export const useSubscriptionDialog = () => {
dialogComponentProps: {
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is ignored here and collapses the table to the default
// `md` frame. `w-fit` lets each step hug its content -- the pricing
// `md` frame. `w-fit` lets each step hug its content the pricing
// table fills its 1280px content while the compact confirm/success
// steps shrink (the content root sets its own width per checkoutStep).
renderer: 'reka',
@@ -163,7 +167,7 @@ export const useSubscriptionDialog = () => {
reason: options?.reason,
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
},
dialogComponentProps: legacyPricingDialogProps
dialogComponentProps
})
}

View File

@@ -5,6 +5,5 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth',
PRICING: 'pricing',
DESKTOP_LOGIN: 'desktop_login'
PRICING: 'pricing'
} as const

View File

@@ -44,7 +44,6 @@ export type OnboardingSurveyOption = {
value: string
label?: LocalizedString
labelKey?: string
icon?: string
}
export type OnboardingSurveyFieldCondition = {
@@ -110,11 +109,6 @@ export type RemoteConfig = {
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
free_tier_balance?: {
allowance: number
used: number
remaining: number
}
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean

View File

@@ -49,23 +49,6 @@ describe('TelemetryRegistry', () => {
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
})
it('dispatches trackAuthFailed to every registered provider', () => {
const a: TelemetryProvider = { trackAuthFailed: vi.fn() }
const b: TelemetryProvider = { trackAuthFailed: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = {
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in' as const
}
registry.trackAuthFailed(payload)
expect(a.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload)
expect(b.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload)
})
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
const provider: TelemetryProvider = {
trackAddApiCreditButtonClicked: vi.fn()

View File

@@ -2,7 +2,6 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -76,10 +75,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackAuth?.(metadata))
}
trackAuthFailed(metadata: AuthErrorMetadata): void {
this.dispatch((provider) => provider.trackAuthFailed?.(metadata))
}
trackUserLoggedIn(): void {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}

View File

@@ -1,183 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import { nextTick } from 'vue'
type MockAuthStore = {
isInitialized: boolean
currentUser: { uid: string } | null
}
const hoisted = vi.hoisted(() => ({
authStore: null as unknown as MockAuthStore
}))
vi.mock('@/stores/authStore', async () => {
const { reactive } = await vi.importActual<typeof VueModule>('vue')
hoisted.authStore = reactive<MockAuthStore>({
isInitialized: false,
currentUser: null
})
return { useAuthStore: () => hoisted.authStore }
})
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
const stopHandles: Array<() => void> = []
function installTelemetryBridge() {
const reportFirebaseAuthState = vi.fn()
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: {
capture: vi.fn(),
reportFirebaseAuthState
}
}
return { reportFirebaseAuthState }
}
function startSync(): void {
const stop = syncHostUserIdWithFirebaseAuth()
if (stop) stopHandles.push(stop)
}
describe('host user ID sync', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.authStore.isInitialized = false
hoisted.authStore.currentUser = null
})
afterEach(() => {
while (stopHandles.length) stopHandles.pop()?.()
delete window.__comfyDesktop2
})
it('waits for Firebase auth initialization before reporting a user', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'pending'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
it('reports a restored Firebase session immediately', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('reports an initially signed-out Firebase session', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_out' }]
])
})
it('reports signed out when Firebase finishes initialization', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
})
it('reports account switches, logout, and subsequent login', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-b'
})
hoisted.authStore.currentUser = null
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }],
[{ status: 'signed_in', userId: 'firebase-user-b' }],
[{ status: 'signed_out' }],
[{ status: 'signed_in', userId: 'firebase-user-c' }]
])
})
it('does not report again when Firebase replaces the user object with the same UID', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
reportFirebaseAuthState.mockImplementationOnce(() => {
throw new Error('host unavailable')
})
expect(() => startSync()).not.toThrow()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
})

View File

@@ -1,49 +0,0 @@
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useAuthStore } from '@/stores/authStore'
function safelyReportFirebaseAuthState(report: () => void): void {
try {
report()
} catch {
// A host bridge failure must not block renderer startup or Firebase auth.
}
}
/**
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
* Must run after Pinia and VueFire are installed.
*/
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
const telemetry = window.__comfyDesktop2?.Telemetry
if (!telemetry) return
// Register this Cloud renderer before Firebase resolves. Desktop may host
// multiple Cloud main frames whose isolated browser partitions have
// different auth states, so main owns all cross-WebContents arbitration.
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
)
const authStore = useAuthStore()
return watch(
() =>
authStore.isInitialized
? (authStore.currentUser?.uid ?? null)
: undefined,
(userId) => {
if (userId === undefined) return
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.(
userId === null
? { status: 'signed_out' }
: { status: 'signed_in', userId }
)
)
},
{ immediate: true }
)
}

View File

@@ -1,42 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn().mockResolvedValue(undefined),
page: vi.fn(),
track: vi.fn().mockResolvedValue(undefined),
identify: vi.fn(),
track: vi.fn(),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
}
let resolvedCb: ((user: { id: string }) => void) | undefined
let logoutCb: (() => void) | undefined
const resolvedUserInfo = { value: null as { id: string } | null }
return {
analytics,
load: vi.fn(() => analytics),
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
userEmail: { value: null as string | null },
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
resolvedCb = cb
if (resolvedUserInfo.value) cb(resolvedUserInfo.value)
}),
onUserLogout: vi.fn((cb: () => void) => {
logoutCb = cb
}),
resolvedUserInfo,
resolveUser: (id: string) => {
resolvedUserInfo.value = { id }
resolvedCb?.({ id })
},
logoutUser: () => {
resolvedUserInfo.value = null
logoutCb?.()
},
resetCallbacks: () => {
resolvedCb = undefined
logoutCb = undefined
resolvedUserInfo.value = null
}
resolveUser: (id: string) => resolvedCb?.({ id }),
logoutUser: () => logoutCb?.()
}
})
@@ -47,8 +31,6 @@ vi.mock('@customerio/cdp-analytics-browser', () => ({
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userEmail: hoisted.userEmail,
resolvedUserInfo: hoisted.resolvedUserInfo,
onUserResolved: hoisted.onUserResolved,
onUserLogout: hoisted.onUserLogout
})
@@ -72,32 +54,14 @@ function createProvider(
return new CustomerIoTelemetryProvider()
}
function createDeferred() {
let resolve = () => {}
const promise = new Promise<void>((complete) => {
resolve = complete
})
return { promise, resolve }
}
describe('CustomerIoTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.resetCallbacks()
hoisted.load.mockReturnValue(hoisted.analytics)
hoisted.analytics.identify.mockResolvedValue(undefined)
hoisted.analytics.track.mockResolvedValue(undefined)
hoisted.analytics.reset.mockReset().mockResolvedValue(undefined)
hoisted.analytics.register.mockResolvedValue(undefined)
hoisted.userEmail.value = null
window.__CONFIG__ = {} as typeof window.__CONFIG__
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('loads the client and registers the in-app plugin with the site id', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -109,89 +73,6 @@ describe('CustomerIoTelemetryProvider', () => {
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('reports the current page after registering the in-app plugin', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.page.mock.invocationCallOrder[0]
)
})
it('queues page views until the in-app plugin is registered', async () => {
let resolveRegistration: (() => void) | undefined
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve
})
hoisted.analytics.register.mockReturnValue(registration)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).not.toHaveBeenCalled()
resolveRegistration?.()
await vi.waitFor(() =>
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
)
})
it('reports client-side route changes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).not.toHaveBeenCalled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
})
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const registrationError = new Error('in-app setup failed')
hoisted.analytics.register.mockRejectedValue(registrationError)
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'execution_start',
SOURCE
)
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith(
'Failed to initialize Customer.io in-app plugin:',
registrationError
)
provider.trackAddApiCreditButtonClicked()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:add_api_credit_button_clicked',
SOURCE
)
)
provider.trackPageView('settings', {
path: 'https://cloud.comfy.org/settings'
})
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()
@@ -208,19 +89,13 @@ describe('CustomerIoTelemetryProvider', () => {
expect(hoisted.load).not.toHaveBeenCalled()
})
it('identifies the resolved user with uid and email traits', async () => {
it('identifies the person by uid only on auth resolve', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.userEmail.value = 'user@example.com'
hoisted.resolveUser('test-uid-7f3a9c')
await vi.waitFor(() =>
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
'test-uid-7f3a9c',
{ email: 'user@example.com' }
)
)
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
})
it('identifies with the configured user_id override without waiting for auth', async () => {
@@ -233,54 +108,8 @@ describe('CustomerIoTelemetryProvider', () => {
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
'forced-uid',
undefined
)
expect(hoisted.onUserResolved).toHaveBeenCalledOnce()
})
it('identifies a restored session with the configured user id once', async () => {
hoisted.userEmail.value = 'restored@example.com'
hoisted.resolvedUserInfo.value = { id: 'resolved-uid' }
createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid', {
email: 'restored@example.com'
})
})
it('re-identifies with the configured user id after logout and re-login', async () => {
createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
await vi.dynamicImportSettled()
hoisted.logoutUser()
hoisted.userEmail.value = 'returning@example.com'
hoisted.resolveUser('resolved-uid')
await vi.waitFor(() =>
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
2,
'forced-uid',
{ email: 'returning@example.com' }
)
)
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
})
it('identifies before flushing events buffered before the SDK loads', async () => {
@@ -300,99 +129,13 @@ describe('CustomerIoTelemetryProvider', () => {
expect(identifyOrder).toBeLessThan(trackOrder)
})
it('restores the resolved user after flushing an older auth event', async () => {
let activeUser: string | null = null
const trackedUsers: Array<[string, string | null]> = []
hoisted.analytics.identify.mockImplementation((userId: string) => {
activeUser = userId
return Promise.resolve()
})
hoisted.analytics.track.mockImplementation((event: string) => {
trackedUsers.push([event, activeUser])
return Promise.resolve()
})
hoisted.userEmail.value = 'current@example.com'
hoisted.resolvedUserInfo.value = { id: 'current-uid' }
const provider = createProvider()
provider.trackAuth({
user_id: 'queued-uid',
email: 'queued@example.com'
})
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
await vi.waitFor(() =>
expect(hoisted.analytics.identify.mock.calls).toEqual([
['current-uid', { email: 'current@example.com' }],
['queued-uid', { email: 'queued@example.com' }],
['current-uid', { email: 'current@example.com' }]
])
)
expect(activeUser).toBe('current-uid')
expect(trackedUsers).toEqual([
['app:user_auth_completed', 'queued-uid'],
['execution_start', 'current-uid']
])
})
it('resets identity after flushing auth for a signed-out user', async () => {
let activeUser: string | null = null
let trackedUser: string | null = null
hoisted.analytics.identify.mockImplementation((userId: string) => {
activeUser = userId
return Promise.resolve()
})
hoisted.analytics.track.mockImplementation(() => {
trackedUser = activeUser
return Promise.resolve()
})
hoisted.analytics.reset.mockImplementation(() => {
activeUser = null
})
const provider = createProvider()
provider.trackAuth({
user_id: 'queued-uid',
email: 'queued@example.com'
})
await vi.dynamicImportSettled()
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
expect(trackedUser).toBe('queued-uid')
expect(activeUser).toBeNull()
})
it('resets on logout', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.logoutUser()
await vi.waitFor(() =>
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
)
})
it('continues tracking after reset fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.analytics.reset.mockRejectedValueOnce(new Error('reset failed'))
const provider = createProvider()
await vi.dynamicImportSettled()
hoisted.logoutUser()
provider.trackWorkflowExecution()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'execution_start',
SOURCE
)
)
expect(console.error).toHaveBeenCalledWith(
'Failed to process Customer.io operation:',
expect.any(Error)
)
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
})
const DIRECT_EVENTS: Array<{
@@ -446,7 +189,6 @@ describe('CustomerIoTelemetryProvider', () => {
invoke: (p) =>
p.trackShareFlow({
step: 'dialog_opened',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
}),
@@ -467,280 +209,10 @@ describe('CustomerIoTelemetryProvider', () => {
invoke(provider)
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
)
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
}
)
it('awaits auth identification before tracking without raw identifiers', async () => {
const identifyResult = createDeferred()
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
const provider = createProvider()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'uid-1',
email: 'person@example.com',
share_id: 'share-1'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.identify).toHaveBeenCalledWith('uid-1', {
email: 'person@example.com'
})
expect(hoisted.analytics.track).not.toHaveBeenCalled()
identifyResult.resolve()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{
...SOURCE,
method: 'google',
is_new_user: true,
user_id: 'uid-1'
}
)
)
expect(hoisted.analytics.identify.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.track.mock.invocationCallOrder[0]
)
})
it('reuses matching resolved-user identification for auth delivery', async () => {
const identifyResult = createDeferred()
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
const provider = createProvider()
await vi.dynamicImportSettled()
hoisted.userEmail.value = 'person@example.com'
hoisted.resolveUser('uid-1')
provider.trackAuth({
user_id: 'uid-1',
email: 'person@example.com'
})
await vi.waitFor(() =>
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
)
expect(hoisted.analytics.track).not.toHaveBeenCalled()
identifyResult.resolve()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{ ...SOURCE, user_id: 'uid-1' }
)
)
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
})
it('tracks auth before resetting identity on logout', async () => {
const identifyResult = createDeferred()
const trackResult = createDeferred()
let activeUser: string | null = null
let trackedUser: string | null = null
hoisted.analytics.identify.mockImplementationOnce((userId: string) => {
activeUser = userId
return identifyResult.promise
})
hoisted.analytics.track.mockImplementationOnce(() => {
trackedUser = activeUser
return trackResult.promise
})
hoisted.analytics.reset.mockImplementationOnce(() => {
activeUser = null
})
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
user_id: 'uid-1',
email: 'person@example.com'
})
hoisted.logoutUser()
identifyResult.resolve()
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
expect(trackedUser).toBe('uid-1')
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.reset.mock.invocationCallOrder[0]
)
trackResult.resolve()
})
it('restores signed-out identity when auth delivery follows logout', async () => {
let activeUser: string | null = null
let trackedUser: string | null = null
hoisted.analytics.identify.mockImplementation((userId: string) => {
activeUser = userId
return Promise.resolve()
})
hoisted.analytics.track.mockImplementation(() => {
trackedUser = activeUser
return Promise.resolve()
})
hoisted.analytics.reset.mockImplementation(() => {
activeUser = null
})
const provider = createProvider()
await vi.dynamicImportSettled()
hoisted.userEmail.value = 'person@example.com'
hoisted.resolveUser('uid-1')
hoisted.logoutUser()
await vi.waitFor(() =>
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
)
provider.trackAuth({
user_id: 'uid-1',
email: 'person@example.com'
})
await vi.waitFor(() =>
expect(hoisted.analytics.reset).toHaveBeenCalledTimes(2)
)
expect(trackedUser).toBe('uid-1')
expect(activeUser).toBeNull()
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.reset.mock.invocationCallOrder[1]
)
})
it('restores a configured identity after tracking auth with the Firebase uid', async () => {
const provider = createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
await vi.dynamicImportSettled()
hoisted.analytics.identify.mockClear()
provider.trackAuth({
user_id: 'firebase-uid',
email: 'person@example.com'
})
await vi.waitFor(() =>
expect(hoisted.analytics.identify.mock.calls).toEqual([
['firebase-uid', { email: 'person@example.com' }],
['forced-uid', undefined]
])
)
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.identify.mock.invocationCallOrder[1]
)
})
it('does not reset identity when login resolution follows auth tracking', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
hoisted.userEmail.value = 'person@example.com'
hoisted.resolveUser('uid-1')
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{ ...SOURCE, user_id: 'uid-1' }
)
)
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
expect(hoisted.analytics.reset).not.toHaveBeenCalled()
})
it('does not stall later events when identification never settles', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.analytics.identify.mockReturnValueOnce(new Promise(() => {}))
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
provider.trackWorkflowExecution()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10_000)
await vi.waitFor(() =>
expect(hoisted.analytics.track.mock.calls).toEqual([
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
['execution_start', SOURCE]
])
)
expect(console.error).toHaveBeenCalledWith(
'Failed to identify Customer.io user:',
expect.any(Error)
)
})
it('tracks auth after identifying a user without an email', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ user_id: 'uid-without-email' })
await vi.waitFor(() =>
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
'uid-without-email',
undefined
)
)
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{ ...SOURCE, user_id: 'uid-without-email' }
)
)
})
it('tracks auth without identifying when user_id is absent', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ method: 'google', email: 'person@example.com' })
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{ ...SOURCE, method: 'google' }
)
)
expect(hoisted.analytics.identify).not.toHaveBeenCalled()
})
it('tracks auth after identification fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.analytics.identify.mockRejectedValueOnce(
new Error('identify failed')
)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
user_id: 'uid-1',
email: 'person@example.com'
})
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:user_auth_completed',
{ ...SOURCE, user_id: 'uid-1' }
)
)
expect(console.error).toHaveBeenCalledWith(
'Failed to identify Customer.io user:',
expect.any(Error)
)
})
it('flushes events buffered before load once, in order', async () => {
const provider = createProvider()
provider.trackWorkflowExecution()
@@ -755,67 +227,6 @@ describe('CustomerIoTelemetryProvider', () => {
])
})
it('waits for queued auth identification before later events', async () => {
const identifyResult = createDeferred()
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
const provider = createProvider()
provider.trackAuth({
user_id: 'uid-1',
email: 'person@example.com'
})
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
identifyResult.resolve()
await vi.waitFor(() =>
expect(hoisted.analytics.track.mock.calls).toEqual([
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
['execution_start', SOURCE]
])
)
})
it('does not wait for event delivery before handing off later events', async () => {
const trackResult = createDeferred()
hoisted.analytics.track.mockReturnValueOnce(trackResult.promise)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
await vi.waitFor(() =>
expect(hoisted.analytics.track.mock.calls).toEqual([
['execution_start', SOURCE],
['app:add_api_credit_button_clicked', SOURCE]
])
)
trackResult.resolve()
})
it('snapshots resolved user email before queued identification', async () => {
const identifyResult = createDeferred()
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ user_id: 'blocking-uid' })
hoisted.userEmail.value = 'first@example.com'
hoisted.resolveUser('resolved-uid')
hoisted.userEmail.value = 'second@example.com'
identifyResult.resolve()
await vi.waitFor(() =>
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
2,
'resolved-uid',
{ email: 'first@example.com' }
)
)
})
it('disables tracking when the SDK fails to load', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.load.mockImplementation(() => {
@@ -842,12 +253,12 @@ describe('CustomerIoTelemetryProvider', () => {
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
)
expect(console.error).toHaveBeenCalledWith(
'Failed to track Customer.io event:',
expect.any(Error)
expect(console.error).toHaveBeenCalledWith(
'Failed to track Customer.io event:',
expect.any(Error)
)
)
})
})

View File

@@ -1,14 +1,11 @@
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
import { omit, withTimeout } from 'es-toolkit'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import type { AuthUserInfo } from '@/types/authTypes'
import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
PageViewMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
@@ -19,17 +16,9 @@ import type {
export const EVENT_SOURCE = 'web-sdk'
const SDK_OPERATION_TIMEOUT_MS = 10_000
interface QueuedEvent {
event: string
properties: Record<string, unknown>
identity?: CustomerIoIdentity
}
interface CustomerIoIdentity {
userId: string
email?: string
}
/**
@@ -42,12 +31,7 @@ interface CustomerIoIdentity {
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private isPageViewTrackingReady = false
private eventQueue: QueuedEvent[] = []
private pageViewQueued = false
private identifiedUser: CustomerIoIdentity | null = null
private sessionIdentity: CustomerIoIdentity | null = null
private operationQueue: Promise<void> = Promise.resolve()
constructor() {
const {
@@ -55,7 +39,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
site_id: siteId,
user_id: userIdOverride
} = window.__CONFIG__?.customer_io ?? {}
this.sessionIdentity = userIdOverride ? { userId: userIdOverride } : null
if (!writeKey || !siteId) {
this.isEnabled = false
return
@@ -64,7 +47,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
const inAppRegistration = analytics.register(
void analytics.register(
InAppPlugin({
siteId,
events: null,
@@ -77,40 +60,14 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
this.analytics = analytics
const currentUser = useCurrentUser()
const identifyResolvedUser = (user: AuthUserInfo) => {
const identity = {
userId: userIdOverride || user.id,
email: currentUser.userEmail.value || undefined
}
this.sessionIdentity = identity
return this.enqueueOperation(() => this.identify(identity))
if (userIdOverride) {
void analytics.identify(userIdOverride)
} else {
currentUser.onUserResolved((user) => void analytics.identify(user.id))
}
currentUser.onUserLogout(() => void analytics.reset())
if (userIdOverride && !currentUser.resolvedUserInfo.value) {
void this.enqueueOperation(() =>
this.identify({ userId: userIdOverride })
)
}
currentUser.onUserResolved((user) => {
void identifyResolvedUser(user)
})
currentUser.onUserLogout(() => {
this.sessionIdentity = null
void this.enqueueOperation(() => this.resetIdentity())
})
void this.flushQueue()
void inAppRegistration
.catch((error) => {
console.error(
'Failed to initialize Customer.io in-app plugin:',
error
)
})
.finally(() => {
this.isPageViewTrackingReady = true
this.flushPageView()
})
this.flushQueue()
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
@@ -119,134 +76,32 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
}
private enqueueOperation(
operation: () => Promise<void> | void
): Promise<void> {
this.operationQueue = this.operationQueue.then(operation).catch((error) => {
console.error('Failed to process Customer.io operation:', error)
})
return this.operationQueue
}
private async resetIdentity(): Promise<void> {
this.identifiedUser = null
const analytics = this.analytics
if (!analytics) return
await withTimeout(async () => {
await analytics.reset()
}, SDK_OPERATION_TIMEOUT_MS)
}
private async restoreSessionIdentity(): Promise<void> {
if (this.sessionIdentity) {
await this.identify(this.sessionIdentity)
} else {
await this.resetIdentity()
}
}
private async identify(identity: CustomerIoIdentity): Promise<void> {
const analytics = this.analytics
if (!analytics) return
if (
this.identifiedUser?.userId === identity.userId &&
this.identifiedUser.email === identity.email
) {
return
}
this.identifiedUser = identity
try {
await withTimeout(async () => {
await analytics.identify(
identity.userId,
identity.email ? { email: identity.email } : undefined
)
}, SDK_OPERATION_TIMEOUT_MS)
} catch (error) {
this.identifiedUser = null
console.error('Failed to identify Customer.io user:', error)
}
}
private async send(
event: string,
properties: Record<string, unknown>,
identity?: CustomerIoIdentity
): Promise<void> {
const analytics = this.analytics
if (!analytics) return
if (identity) await this.identify(identity)
void analytics.track(event, properties).catch((error) => {
private send(event: string, properties: Record<string, unknown>): void {
void this.analytics?.track(event, properties)?.catch((error) => {
console.error('Failed to track Customer.io event:', error)
})
if (identity) await this.restoreSessionIdentity()
}
private track(
event: string,
metadata?: TelemetryEventProperties,
identity?: CustomerIoIdentity
): void {
private track(event: string, metadata?: TelemetryEventProperties): void {
if (!this.isEnabled) return
const properties = { ...metadata, event_source: EVENT_SOURCE }
if (this.analytics) {
void this.enqueueOperation(() => this.send(event, properties, identity))
this.send(event, properties)
} else {
this.eventQueue.push({ event, properties, identity })
this.eventQueue.push({ event, properties })
}
}
private async flushQueue(): Promise<void> {
private flushQueue(): void {
if (!this.analytics) return
const queue = this.eventQueue
for (const { event, properties } of this.eventQueue) {
this.send(event, properties)
}
this.eventQueue = []
await this.enqueueOperation(async () => {
for (const { event, properties, identity } of queue) {
await this.send(event, properties, identity)
}
})
}
private sendPageView(): void {
void this.analytics?.page()?.catch((error) => {
console.error('Failed to track Customer.io page view:', error)
})
}
private flushPageView(): void {
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
return
}
this.pageViewQueued = false
this.sendPageView()
}
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
if (!this.isEnabled) return
if (!this.isPageViewTrackingReady) {
this.pageViewQueued = true
return
}
this.sendPageView()
}
trackAuth(metadata: AuthMetadata): void {
const identity = metadata.user_id
? {
userId: metadata.user_id,
email: metadata.email || undefined
}
: undefined
this.track(
TelemetryEvents.USER_AUTH_COMPLETED,
omit(metadata, ['email', 'share_id']),
identity
)
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackSubscription(
@@ -282,6 +137,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.track(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
this.track(TelemetryEvents.SHARE_FLOW, metadata)
}
}

View File

@@ -361,24 +361,6 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures auth failure events with metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuthFailed({
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_FAILED,
{
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
}
)
})
it.for([
['flow_opened', TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED, {}],
['confirmed', TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED, {}],

View File

@@ -11,7 +11,6 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -354,10 +353,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackAuthFailed(metadata: AuthErrorMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_FAILED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}

View File

@@ -33,7 +33,6 @@ export type PaymentIntentSource =
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
| 'free_tier_quota'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
@@ -53,45 +52,20 @@ export interface AuthMetadata {
utm_campaign?: string
}
export type AuthFlowAction =
| 'email_sign_in'
| 'email_sign_up'
| 'google_sign_in'
| 'google_sign_up'
| 'github_sign_in'
| 'github_sign_up'
| 'password_reset'
/**
* Metadata for failed authentication attempts
*/
export interface AuthErrorMetadata {
error_code: string
auth_action: AuthFlowAction
}
/**
* Survey field ids mapped to answers. Fields are backend-overridable, so all
* are optional.
* Survey response data for user profiling
* Maps 1-to-1 with actual survey fields
*/
export interface SurveyResponses {
// Current default schema (see defaultSurveySchema.ts)
intent?: string | string[]
intentOther?: string
experience?: string
focus?: string
source?: string
sourceOther?: string
source_social?: string
// Legacy fields — only emitted by older backend-supplied schemas, never by
// the current default. Kept so historical responses still typecheck.
familiarity?: string
industry?: string
useCase?: string
making?: string[]
role?: string
teamSize?: string
source?: string
usage?: string
intent?: string[]
}
export interface SurveyResponsesNormalized extends SurveyResponses {
@@ -551,7 +525,6 @@ export interface TelemetryProvider {
// Authentication flow events
trackSignupOpened?(): void
trackAuth?(metadata: AuthMetadata): void
trackAuthFailed?(metadata: AuthErrorMetadata): void
trackUserLoggedIn?(): void
// Subscription flow events
@@ -664,7 +637,6 @@ export const TelemetryEvents = {
// Authentication Flow
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_AUTH_FAILED: 'app:user_auth_failed',
USER_LOGGED_IN: 'app:user_logged_in',
// Subscription Flow
@@ -771,7 +743,6 @@ export type ExecutionTriggerSource =
*/
export type TelemetryEventProperties =
| AuthMetadata
| AuthErrorMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext

View File

@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
canRunWorkflows: true
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
canRunWorkflows: billingMock.canRunWorkflows
isActiveSubscription: billingMock.isActiveSubscription
})
}))
@@ -77,14 +77,14 @@ const nodeErrors: Record<string, NodeError> = {
function renderControls({
hasError = false,
canRunWorkflows = true,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
canRunWorkflows?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.canRunWorkflows = canRunWorkflows
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -120,7 +120,7 @@ function renderControls({
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.canRunWorkflows = true
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
({ mobile }) => {
renderControls({
hasError: true,
canRunWorkflows: false,
isActiveSubscription: false,
mobile
})

View File

@@ -11,7 +11,6 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -29,7 +28,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { canRunWorkflows } = useBillingContext()
const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -55,7 +54,7 @@ const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(canRunWorkflows) &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
@@ -153,7 +152,10 @@ function handleDragDrop() {
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
@@ -208,7 +210,10 @@ function handleDragDrop() {
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<Button
v-else
variant="primary"
@@ -224,7 +229,6 @@ function handleDragDrop() {
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<FreeTierQuota />
</section>
</div>
</div>

View File

@@ -4,17 +4,33 @@ import {
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
const creditsBadges = useCreditsBadgesInGraph()
const { isCreditsBadge } = usePriceBadge()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">

View File

@@ -16,7 +16,6 @@ import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -119,11 +118,6 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
keys: ['desktop_login_code'],
stripAfterCapture: true
}
])
@@ -255,8 +249,6 @@ if (isCloud) {
// User is logged in and accessing protected route
return next()
})
installDesktopLoginRedemption(router)
}
export default router

View File

@@ -24,7 +24,6 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -1814,7 +1813,6 @@ export class ComfyApp {
isPartialExecution
})
}
useFreeTierQuota().trackRun()
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -27,7 +27,6 @@ export function useBillingContext(): BillingContext {
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => false),
canRunWorkflows: computed(() => false),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),