mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
5 Commits
shihchi/co
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e1344320 | ||
|
|
38c6fd9b88 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 |
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
offset: HEADER_OFFSET_PX,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
@@ -75,9 +76,10 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -281,8 +282,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
legacyShowSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
22
src/composables/useViewErrorsInGraph.ts
Normal file
22
src/composables/useViewErrorsInGraph.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -25,6 +25,6 @@ function handleClose() {
|
||||
}
|
||||
|
||||
function handleSubscribe() {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -140,7 +140,10 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
false
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
)
|
||||
|
||||
// Shows loading affordances
|
||||
@@ -169,7 +172,10 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
false
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -180,7 +186,8 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
|
||||
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'team_700',
|
||||
'yearly'
|
||||
'yearly',
|
||||
{ paymentIntentSource: 'deep_link' }
|
||||
)
|
||||
// Team never goes through the personal checkout path
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
|
||||
@@ -94,7 +94,9 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
isTeamCheckout.value = true
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle)
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle, {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,7 +114,10 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
|
||||
@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
telemetry?.trackAddApiCreditButtonClicked()
|
||||
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
}
|
||||
|
||||
async function handleWindowFocus() {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
import FreeTierDialogContent from './FreeTierDialogContent.vue'
|
||||
|
||||
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
|
||||
@@ -15,7 +17,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
function renderComponent(props?: { reason?: PaymentIntentSource }) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -23,6 +25,7 @@ function renderComponent() {
|
||||
})
|
||||
|
||||
return render(FreeTierDialogContent, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
@@ -43,4 +46,18 @@ describe('FreeTierDialogContent', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the generic copy for intent reasons outside the credits variants', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'subscribe_to_run' })
|
||||
expect(
|
||||
screen.getByText('Your credits refresh on Jul 15, 2026.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('swaps to the out-of-credits copy without the refresh line', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!reason || reason === 'subscription_required'"
|
||||
v-if="!isCreditsBlockedVariant"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -65,10 +65,7 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="
|
||||
(!reason || reason === 'subscription_required') &&
|
||||
formattedRenewalDate
|
||||
"
|
||||
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -88,7 +85,7 @@
|
||||
@click="$emit('upgrade')"
|
||||
>
|
||||
{{
|
||||
reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
isCreditsBlockedVariant
|
||||
? $t('subscription.freeTier.upgradeCta')
|
||||
: $t('subscription.freeTier.subscribeCta')
|
||||
}}
|
||||
@@ -103,12 +100,12 @@ import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
defineProps<{
|
||||
reason?: SubscriptionDialogReason
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -129,4 +126,10 @@ const formattedRenewalDate = computed(() => {
|
||||
})
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
|
||||
// Only these two variants replace the generic free-tier copy; any other
|
||||
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
|
||||
const isCreditsBlockedVariant = computed(
|
||||
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -261,6 +261,7 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
|
||||
@@ -341,6 +342,7 @@ describe('PricingTable', () => {
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
@@ -366,6 +368,7 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,13 +277,19 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import {
|
||||
recordPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
@@ -321,6 +327,10 @@ interface PricingTierConfig {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chooseTeamWorkspace: []
|
||||
}>()
|
||||
@@ -463,16 +473,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
} as const
|
||||
const previousPlan = currentPlanDescriptor.value
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
})
|
||||
}
|
||||
const beginCheckoutMetadata = userId.value
|
||||
? {
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change' as const,
|
||||
...(reason ? { payment_intent_source: reason } : {}),
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
}
|
||||
: null
|
||||
// Pass the target tier to create a deep link to subscription update confirmation
|
||||
const checkoutTier = getCheckoutTier(
|
||||
targetPlan.tierKey,
|
||||
@@ -487,29 +498,39 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
if (downgrade) {
|
||||
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
|
||||
await accessBillingPortal()
|
||||
const didOpenPortal = await accessBillingPortal()
|
||||
if (didOpenPortal && beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
|
||||
}
|
||||
} else {
|
||||
const didOpenPortal = await accessBillingPortal(checkoutTier)
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
payment_intent_source: reason,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
|
||||
...(previousPlan
|
||||
? { previous_cycle: previousPlan.billingCycle }
|
||||
: {})
|
||||
})
|
||||
if (beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
beginCheckoutMetadata,
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
currentBillingCycle.value,
|
||||
true
|
||||
)
|
||||
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
|
||||
paymentIntentSource: reason
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleSubscribe = () => {
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_now_button' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
@click="
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
|
||||
<PricingTable
|
||||
:reason
|
||||
class="flex-1"
|
||||
@choose-team-workspace="handleChooseTeam"
|
||||
/>
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
@@ -157,11 +161,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
const { onClose, reason, onChooseTeam } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
reason?: PaymentIntentSource
|
||||
onChooseTeam?: () => void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ export function useAccountPreconditionDialog() {
|
||||
)
|
||||
return
|
||||
case 'subscription':
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
void dialogService.showSubscriptionRequiredDialog({
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
return
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
|
||||
@@ -55,12 +55,6 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
describe('usePricingTableUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -96,9 +90,6 @@ describe('usePricingTableUrlLoader', () => {
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
reason: 'deep_link'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
@@ -150,7 +141,6 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies, strips, and clears together when the user is not eligible', async () => {
|
||||
@@ -161,7 +151,6 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { other: 'param' }
|
||||
})
|
||||
@@ -230,7 +219,6 @@ describe('usePricingTableUrlLoader', () => {
|
||||
)
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -62,7 +61,6 @@ export function usePricingTableUrlLoader() {
|
||||
const planMode =
|
||||
param === 'team' || param === 'personal' ? param : undefined
|
||||
|
||||
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
|
||||
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -237,14 +237,7 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = (options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) => {
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
|
||||
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
|
||||
void showSubscriptionRequiredDialog(options)
|
||||
}
|
||||
|
||||
@@ -277,7 +270,7 @@ function useSubscriptionInternal() {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscription_required' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,23 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
}))
|
||||
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockIsCloud,
|
||||
mockTrackHelpResourceClicked,
|
||||
mockTrackAddApiCreditButtonClicked
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockTrackHelpResourceClicked: vi.fn()
|
||||
mockTrackHelpResourceClicked: vi.fn(),
|
||||
mockTrackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () =>
|
||||
mockIsCloud.value
|
||||
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
|
||||
? {
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked,
|
||||
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
|
||||
}
|
||||
: null
|
||||
}))
|
||||
|
||||
@@ -69,6 +77,9 @@ describe('useSubscriptionActions', () => {
|
||||
const { handleAddApiCredits } = useSubscriptionActions()
|
||||
handleAddApiCredits()
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
|
||||
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ export function useSubscriptionActions() {
|
||||
})
|
||||
|
||||
const handleAddApiCredits = () => {
|
||||
telemetry?.trackAddApiCreditButtonClicked({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockShowLayoutDialog = vi.fn()
|
||||
const mockShowTeamWorkspacesDialog = vi.fn()
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
|
||||
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
|
||||
@@ -60,10 +62,15 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isFreeTier: mockIsFreeTier,
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan,
|
||||
tier: mockTier
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
@@ -80,6 +87,7 @@ describe('useSubscriptionDialog', () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTier.value = 'FREE'
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
mockCanManageSubscription.value = true
|
||||
@@ -198,6 +206,51 @@ describe('useSubscriptionDialog', () => {
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the caller reason and current tier', () => {
|
||||
mockTier.value = 'STANDARD'
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
current_tier: 'standard',
|
||||
reason: 'upgrade_to_add_credits'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks modal_opened on the workspace (unified) path too', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'subscribe_to_run' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track modal_opened for the inactive member dialog', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
@@ -235,6 +288,20 @@ describe('useSubscriptionDialog', () => {
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the reason for the free-tier dialog', () => {
|
||||
mockIsFreeTier.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'out_of_credits' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -11,14 +13,8 @@ const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: PaymentIntentSource
|
||||
/**
|
||||
* Forces the unified pricing dialog to open on a specific plan tab,
|
||||
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
|
||||
@@ -38,6 +34,17 @@ export const useSubscriptionDialog = () => {
|
||||
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
|
||||
}
|
||||
|
||||
// Fired here — the choke point every paywall/pricing dialog variant passes
|
||||
// through — so both the legacy and workspace billing paths emit it.
|
||||
function trackModalOpened(reason?: PaymentIntentSource) {
|
||||
// Resolved lazily to avoid the useBillingContext import cycle (see below).
|
||||
const { tier } = useBillingContext()
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
reason
|
||||
})
|
||||
}
|
||||
|
||||
function showPricingTable(options?: SubscriptionDialogOptions) {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -71,6 +78,8 @@ export const useSubscriptionDialog = () => {
|
||||
return
|
||||
}
|
||||
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
// Shared dialog shell styling for both variants.
|
||||
const dialogComponentProps = {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
@@ -167,6 +176,8 @@ export const useSubscriptionDialog = () => {
|
||||
// (not at composable setup) to avoid the useBillingContext import cycle.
|
||||
const { isFreeTier } = useBillingContext()
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
|
||||
@@ -236,7 +247,7 @@ export const useSubscriptionDialog = () => {
|
||||
sessionStorage.removeItem(RESUME_PRICING_KEY)
|
||||
|
||||
if (!workspaceStore.isInPersonalWorkspace) {
|
||||
showPricingTable()
|
||||
showPricingTable({ reason: 'team_upgrade_resume' })
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
consumePendingSubscriptionCheckoutSuccess,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
} from './subscriptionCheckoutTracker'
|
||||
|
||||
const activeProStatus = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as const
|
||||
|
||||
describe('subscriptionCheckoutTracker', () => {
|
||||
beforeEach(() => {
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
})
|
||||
|
||||
it('round-trips payment_intent_source from attempt to success metadata', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
tier: 'pro',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits payment_intent_source when the attempt had none', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).not.toBeNull()
|
||||
expect(metadata).not.toHaveProperty('payment_intent_source')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
TierKey
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
|
||||
import type {
|
||||
BeginCheckoutMetadata,
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType,
|
||||
SubscriptionSuccessMetadata
|
||||
} from '@/platform/telemetry/types'
|
||||
|
||||
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
const VALID_TIER_KEYS = new Set<TierKey>([
|
||||
@@ -23,7 +28,6 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
|
||||
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
|
||||
'comfy:subscription-checkout-attempt-changed'
|
||||
|
||||
type CheckoutType = 'new' | 'change'
|
||||
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
|
||||
|
||||
interface SubscriptionStatusSnapshot {
|
||||
@@ -32,22 +36,24 @@ interface SubscriptionStatusSnapshot {
|
||||
subscription_duration?: SubscriptionDuration | null
|
||||
}
|
||||
|
||||
interface PendingSubscriptionCheckoutAttempt {
|
||||
export interface PendingSubscriptionCheckoutAttempt {
|
||||
attempt_id: string
|
||||
started_at_ms: number
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: CheckoutType
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface RecordPendingSubscriptionCheckoutAttemptInput {
|
||||
interface PendingSubscriptionCheckoutAttemptInput {
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: CheckoutType
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
const dispatchPendingCheckoutChangeEvent = () => {
|
||||
@@ -168,6 +174,9 @@ const normalizeAttempt = (
|
||||
...(candidate.previous_cycle === 'monthly' ||
|
||||
candidate.previous_cycle === 'yearly'
|
||||
? { previous_cycle: candidate.previous_cycle }
|
||||
: {}),
|
||||
...(typeof candidate.payment_intent_source === 'string'
|
||||
? { payment_intent_source: candidate.payment_intent_source }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -224,20 +233,27 @@ const getPendingSubscriptionCheckoutAttempt =
|
||||
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
|
||||
getPendingSubscriptionCheckoutAttempt() !== null
|
||||
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: RecordPendingSubscriptionCheckoutAttemptInput
|
||||
export const createPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
const storage = getStorage()
|
||||
const attempt: PendingSubscriptionCheckoutAttempt = {
|
||||
return {
|
||||
attempt_id: createAttemptId(),
|
||||
started_at_ms: Date.now(),
|
||||
tier: input.tier,
|
||||
cycle: input.cycle,
|
||||
checkout_type: input.checkout_type,
|
||||
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
|
||||
...(input.payment_intent_source
|
||||
? { payment_intent_source: input.payment_intent_source }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export const persistPendingSubscriptionCheckoutAttempt = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return attempt
|
||||
}
|
||||
@@ -255,6 +271,21 @@ export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
return attempt
|
||||
}
|
||||
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt =>
|
||||
persistPendingSubscriptionCheckoutAttempt(
|
||||
createPendingSubscriptionCheckoutAttempt(input)
|
||||
)
|
||||
|
||||
export const withPendingCheckoutAttemptId = (
|
||||
metadata: BeginCheckoutMetadata,
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): BeginCheckoutMetadata => ({
|
||||
...metadata,
|
||||
checkout_attempt_id: attempt.attempt_id
|
||||
})
|
||||
|
||||
const didAttemptSucceed = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt,
|
||||
status: SubscriptionStatusSnapshot
|
||||
@@ -287,6 +318,9 @@ export const consumePendingSubscriptionCheckoutSuccess = (
|
||||
cycle: attempt.cycle,
|
||||
checkout_type: attempt.checkout_type,
|
||||
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
|
||||
...(attempt.payment_intent_source
|
||||
? { payment_intent_source: attempt.payment_intent_source }
|
||||
: {}),
|
||||
value,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
|
||||
@@ -132,13 +132,14 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'yearly', true)
|
||||
await performSubscriptionCheckout('pro', 'yearly')
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
@@ -150,6 +151,12 @@ describe('performSubscriptionCheckout', () => {
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
JSON.parse(storedAttempt).attempt_id
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/cloud-subscription-checkout/pro-yearly'
|
||||
@@ -186,7 +193,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
@@ -203,11 +210,43 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', {
|
||||
paymentIntentSource: 'out_of_credits'
|
||||
})
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
|
||||
)
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
const pendingAttempt = JSON.parse(storedAttempt)
|
||||
expect(pendingAttempt).toMatchObject({
|
||||
payment_intent_source: 'out_of_credits'
|
||||
})
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
pendingAttempt.attempt_id
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
@@ -222,7 +261,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
|
||||
|
||||
mockUserId.value = 'user-late'
|
||||
authHeader.resolve({ Authorization: 'Bearer test-token' })
|
||||
@@ -235,13 +274,14 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-late',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new'
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
|
||||
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
@@ -250,11 +290,18 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
const storedAttempt = window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
)
|
||||
expect(storedAttempt).toBeNull()
|
||||
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkout_attempt_id: expect.any(String)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,12 +4,19 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
createPendingSubscriptionCheckoutAttempt,
|
||||
persistPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -31,6 +38,11 @@ const getCheckoutAttributionForCloud =
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
interface PerformSubscriptionCheckoutOptions {
|
||||
openInNewTab?: boolean
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
@@ -47,10 +59,12 @@ const getCheckoutAttributionForCloud =
|
||||
export async function performSubscriptionCheckout(
|
||||
tierKey: TierKey,
|
||||
currentBillingCycle: BillingCycle,
|
||||
openInNewTab: boolean = true
|
||||
options: PerformSubscriptionCheckoutOptions = {}
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { openInNewTab = true, paymentIntentSource } = options
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { userId } = storeToRefs(authStore)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -108,14 +122,29 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: paymentIntentSource
|
||||
})
|
||||
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...checkoutAttribution
|
||||
})
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
{
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...(paymentIntentSource
|
||||
? { payment_intent_source: paymentIntentSource }
|
||||
: {}),
|
||||
...checkoutAttribution
|
||||
},
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (openInNewTab) {
|
||||
@@ -123,18 +152,9 @@ export async function performSubscriptionCheckout(
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
} else {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn()
|
||||
}))
|
||||
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
|
||||
vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -16,6 +20,12 @@ vi.mock('@/config/comfyApi', () => ({
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { subscribe: mockSubscribe }
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
}))
|
||||
|
||||
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
|
||||
|
||||
@@ -43,7 +53,9 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
billing_op_id: 'op_1'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly', {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
|
||||
returnUrl: 'https://app.test/payment/success',
|
||||
@@ -51,6 +63,14 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
teamCreditStopId: 'team_700'
|
||||
})
|
||||
expect(assignedHref).toBe('https://stripe.test/pay')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'team',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op_1',
|
||||
payment_intent_source: 'deep_link'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
|
||||
@@ -82,6 +102,16 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not track begin_checkout when subscribe fails', async () => {
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
|
||||
|
||||
await expect(
|
||||
performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
).rejects.toThrow('subscribe failed')
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
interface PerformTeamSubscriptionCheckoutOptions {
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
|
||||
* link: subscribes to the per-credit Team plan at the chosen slider stop and
|
||||
@@ -22,7 +28,8 @@ import type { BillingCycle } from './subscriptionTierRank'
|
||||
*/
|
||||
export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId: string,
|
||||
billingCycle: BillingCycle
|
||||
billingCycle: BillingCycle,
|
||||
options: PerformTeamSubscriptionCheckoutOptions = {}
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -33,6 +40,14 @@ export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId
|
||||
})
|
||||
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType: 'new',
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource: options.paymentIntentSource
|
||||
})
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
// A needs_payment_method response without a URL is unusable: surface it to
|
||||
// the caller's error handling rather than silently dropping the user home
|
||||
|
||||
176
src/platform/settings/components/ColorPaletteMessage.test.ts
Normal file
176
src/platform/settings/components/ColorPaletteMessage.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPaletteMessage from './ColorPaletteMessage.vue'
|
||||
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteService = vi.hoisted(() => ({
|
||||
exportColorPalette: vi.fn(),
|
||||
importColorPalette: vi.fn(),
|
||||
deleteCustomColorPalette: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
palettes: {
|
||||
value: Array<{ id: string; name: string }>
|
||||
}
|
||||
activePaletteId: {
|
||||
value: string
|
||||
}
|
||||
},
|
||||
customPaletteIds: new Set<string>()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal: () => Promise<typeof Pinia>) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/colorPaletteService', () => ({
|
||||
useColorPaletteService: () => mockColorPaletteService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const palettes = ref([
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
])
|
||||
const activePaletteId = ref('builtin')
|
||||
|
||||
mockColorPaletteState.refs = {
|
||||
palettes,
|
||||
activePaletteId
|
||||
}
|
||||
|
||||
return {
|
||||
useColorPaletteStore: () => ({
|
||||
palettes,
|
||||
activePaletteId,
|
||||
isCustomPalette: (paletteId: string) =>
|
||||
mockColorPaletteState.customPaletteIds.has(paletteId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['title', 'disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
data-testid="palette-select"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.id" :value="option.id">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderMessage() {
|
||||
return render(ColorPaletteMessage, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ColorPaletteMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue(null)
|
||||
mockColorPaletteState.customPaletteIds = new Set(['custom'])
|
||||
if (mockColorPaletteState.refs) {
|
||||
mockColorPaletteState.refs.activePaletteId.value = 'builtin'
|
||||
mockColorPaletteState.refs.palettes.value = [
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('exports and deletes the active custom palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.export'))
|
||||
expect(mockColorPaletteService.exportColorPalette).toHaveBeenCalledWith(
|
||||
'builtin'
|
||||
)
|
||||
expect(screen.getByTitle('g.delete')).toBeDisabled()
|
||||
|
||||
await fireEvent.update(screen.getByTestId('palette-select'), 'custom')
|
||||
await userEvent.click(screen.getByTitle('g.delete'))
|
||||
|
||||
expect(
|
||||
mockColorPaletteService.deleteCustomColorPalette
|
||||
).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
|
||||
it('persists imported palettes only when import returns a palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue({
|
||||
id: 'imported',
|
||||
name: 'Imported'
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
'imported'
|
||||
)
|
||||
})
|
||||
})
|
||||
306
src/platform/settings/components/ExtensionPanel.test.ts
Normal file
306
src/platform/settings/components/ExtensionPanel.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionPanel from './ExtensionPanel.vue'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
interface MockExtension {
|
||||
name: string
|
||||
}
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
store: {
|
||||
extensions: [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
] as MockExtension[],
|
||||
inactiveDisabledExtensionNames: ['inactive.pack'],
|
||||
hasThirdPartyExtensions: true,
|
||||
enabled: new Set(['core.color', 'custom.pack', 'readonly.pack']),
|
||||
core: new Set(['core.color']),
|
||||
readOnly: new Set(['readonly.pack']),
|
||||
isExtensionEnabled(name: string) {
|
||||
return this.enabled.has(name)
|
||||
},
|
||||
isCoreExtension(name: string) {
|
||||
return this.core.has(name)
|
||||
},
|
||||
isExtensionReadOnly(name: string) {
|
||||
return this.readOnly.has(name)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/core/api', () => ({
|
||||
FilterMatchMode: {
|
||||
CONTAINS: 'contains'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => mockExtensionState.store
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="extension-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :disabled="disabled" @click="$emit('click', $event)">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section data-testid="extension-message"><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="extension-filter">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
@click="$emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/datatable', () => ({
|
||||
default: {
|
||||
props: ['value', 'selection'],
|
||||
emits: ['update:selection'],
|
||||
template: `
|
||||
<section data-testid="extension-table">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-visible"
|
||||
@click="$emit('update:selection', value)"
|
||||
>
|
||||
select
|
||||
</button>
|
||||
<div v-for="ext in value" :key="ext.name" data-testid="extension-row">
|
||||
{{ ext.name }}
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/column', () => ({
|
||||
default: {
|
||||
template: '<div><slot name="header" /><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/contextmenu', () => ({
|
||||
default: {
|
||||
props: ['model'],
|
||||
methods: {
|
||||
show: vi.fn()
|
||||
},
|
||||
template: `
|
||||
<div data-testid="extension-menu">
|
||||
<button
|
||||
v-for="item in model.filter((entry) => !entry.separator)"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
:disabled="item.disabled"
|
||||
@click="item.command?.()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/tag', () => ({
|
||||
default: {
|
||||
props: ['value'],
|
||||
template: '<span>{{ value }}</span>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/toggleswitch', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
data-testid="extension-toggle"
|
||||
@click="$emit('update:modelValue', !modelValue); $emit('change')"
|
||||
>
|
||||
{{ String(modelValue) }}
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ExtensionPanel, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ExtensionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockExtensionState.store.extensions = [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
]
|
||||
mockExtensionState.store.inactiveDisabledExtensionNames = ['inactive.pack']
|
||||
mockExtensionState.store.hasThirdPartyExtensions = true
|
||||
mockExtensionState.store.enabled = new Set([
|
||||
'core.color',
|
||||
'custom.pack',
|
||||
'readonly.pack'
|
||||
])
|
||||
mockExtensionState.store.core = new Set(['core.color'])
|
||||
mockExtensionState.store.readOnly = new Set(['readonly.pack'])
|
||||
})
|
||||
|
||||
it('filters extensions by all, core, and custom categories', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.core' }))
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.custom' }))
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies selected extension commands without changing read-only rows', async () => {
|
||||
renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByTestId('select-visible'))
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableSelected' })
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).not.toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.enableSelected' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
})
|
||||
|
||||
it('applies bulk commands and disables third-party command when unavailable', async () => {
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.disableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.enableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'custom.pack', 'readonly.pack']
|
||||
)
|
||||
|
||||
unmount()
|
||||
mockExtensionState.store.hasThirdPartyExtensions = false
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
317
src/platform/settings/components/ServerConfigPanel.test.ts
Normal file
317
src/platform/settings/components/ServerConfigPanel.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ServerConfigPanel from './ServerConfigPanel.vue'
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCopy = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockElectronAPI = vi.hoisted(() => ({
|
||||
restartApp: vi.fn()
|
||||
}))
|
||||
|
||||
const mockServerConfigStore = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
serverConfigsByCategory: {
|
||||
value: Record<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
tooltip?: string
|
||||
}>
|
||||
>
|
||||
}
|
||||
serverConfigValues: { value: Record<string, string> }
|
||||
launchArgs: { value: string[] }
|
||||
commandLineArgs: { value: string }
|
||||
modifiedConfigs: {
|
||||
value: Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
}>
|
||||
}
|
||||
},
|
||||
revertChanges: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Pinia>()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => mockToastStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/serverConfigStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const serverConfigsByCategory = ref({
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
})
|
||||
const serverConfigValues = ref({ listen: 'true' })
|
||||
const launchArgs = ref(['--listen'])
|
||||
const commandLineArgs = ref('python main.py --listen')
|
||||
const modifiedConfigs = ref([
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
])
|
||||
|
||||
mockServerConfigStore.refs = {
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs
|
||||
}
|
||||
|
||||
return {
|
||||
useServerConfigStore: () => ({
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs,
|
||||
revertChanges: mockServerConfigStore.revertChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: mockCopy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => mockElectronAPI
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/FormItem.vue', () => ({
|
||||
default: {
|
||||
props: ['id', 'item', 'labelClass'],
|
||||
template: `
|
||||
<label
|
||||
:data-testid="'server-config-' + id"
|
||||
:data-highlighted="String(Boolean(labelClass?.['text-highlight']))"
|
||||
:title="item.tooltip"
|
||||
>
|
||||
{{ item.name }}={{ item.value }}
|
||||
</label>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['ariaLabel'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: {
|
||||
template: '<hr />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot name="icon" /><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ServerConfigPanel, {
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ServerConfigPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
mockElectronAPI.restartApp.mockResolvedValue(undefined)
|
||||
mockServerConfigStore.revertChanges.mockReset()
|
||||
if (mockServerConfigStore.refs) {
|
||||
mockServerConfigStore.refs.serverConfigsByCategory.value = {
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
}
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'true' }
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--listen']
|
||||
mockServerConfigStore.refs.commandLineArgs.value =
|
||||
'python main.py --listen'
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('renders modified configs, translates form items, and copies command line args', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('serverConfig.modifiedConfigs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Listen: false → true')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'title',
|
||||
'Enable listen mode'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-preview')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'false'
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('g.copyToClipboard'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('python main.py --listen')
|
||||
})
|
||||
|
||||
it('reverts, restarts, and suppresses the unmount warning after restart', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.revertChanges' })
|
||||
)
|
||||
expect(mockServerConfigStore.revertChanges).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.restart' })
|
||||
)
|
||||
expect(mockElectronAPI.restartApp).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists launch args and server config values through watchers', async () => {
|
||||
renderPanel()
|
||||
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--cpu']
|
||||
await nextTick()
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'false' }
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.LaunchArgs',
|
||||
['--cpu']
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.ServerConfigValues',
|
||||
{ listen: 'false' }
|
||||
)
|
||||
})
|
||||
|
||||
it('warns on unmount only when modified configs remain', () => {
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = []
|
||||
const empty = renderPanel()
|
||||
empty.unmount()
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
const modified = renderPanel()
|
||||
modified.unmount()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: 'serverConfig.restartRequiredToastSummary',
|
||||
detail: 'serverConfig.restartRequiredToastDetail',
|
||||
life: 10_000
|
||||
})
|
||||
})
|
||||
})
|
||||
542
src/platform/settings/components/SettingDialog.test.ts
Normal file
542
src/platform/settings/components/SettingDialog.test.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SettingDialog from './SettingDialog.vue'
|
||||
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
interface MockSettingTreeNode {
|
||||
key: string
|
||||
label: string
|
||||
leaf?: boolean
|
||||
sortOrder?: number
|
||||
data?: { id: string; name: string; sortOrder?: number }
|
||||
children?: MockSettingTreeNode[]
|
||||
}
|
||||
|
||||
const mockFetchBalance = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockSettingUI = vi.hoisted(() => ({
|
||||
defaultPanel: undefined as string | undefined,
|
||||
refs: null as null | {
|
||||
settingCategories: {
|
||||
value: MockSettingTreeNode[]
|
||||
}
|
||||
navGroups: {
|
||||
value: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingSearch = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
searchQuery: { value: string }
|
||||
inSearch: { value: boolean }
|
||||
searchResultsCategories: { value: Set<string> }
|
||||
matchedNavItemKeys: { value: Set<string> }
|
||||
results: {
|
||||
value: Array<{
|
||||
label: string
|
||||
category?: string
|
||||
settings: Array<{ id: string; name: string; sortOrder?: number }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
handleSearch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<section data-testid="settings-dialog">
|
||||
<header data-testid="left-title"><slot name="leftPanelHeaderTitle" /></header>
|
||||
<aside data-testid="left-panel"><slot name="leftPanel" /></aside>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-actions"><slot name="header-right-area" /></div>
|
||||
<main data-testid="content"><slot name="content" /></main>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder', 'autofocus'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="settings-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:data-autofocus="String(autofocus)"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@change="$emit('search', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavTitle.vue', () => ({
|
||||
default: {
|
||||
props: ['title'],
|
||||
template: '<h3>{{ title }}</h3>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavItem.vue', () => ({
|
||||
default: {
|
||||
props: ['icon', 'badge', 'active'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:data-nav-id="$attrs['data-nav-id']"
|
||||
:data-active="String(active)"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/content/setting/CurrentUserMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="current-user-message">current user</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/ColorPaletteMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="color-palette-message">palette</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/SettingsPanel.vue', () => ({
|
||||
default: {
|
||||
props: ['settingGroups'],
|
||||
template: `
|
||||
<div data-testid="settings-panel">
|
||||
<section v-for="group in settingGroups" :key="group.label">
|
||||
<h4>{{ group.label }}</h4>
|
||||
<span v-for="setting in group.settings" :key="setting.id">
|
||||
{{ setting.id }}
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingUI', async () => {
|
||||
const { computed, defineComponent, h, ref } = await import('vue')
|
||||
|
||||
const settingCategories = ref([
|
||||
{
|
||||
key: 'Comfy',
|
||||
label: 'Comfy',
|
||||
children: [
|
||||
{
|
||||
key: 'General',
|
||||
label: 'General',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.High',
|
||||
label: 'High',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.High', name: 'High', sortOrder: 30 }
|
||||
},
|
||||
{
|
||||
key: 'Comfy.Low',
|
||||
label: 'Low',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Low', name: 'Low', sortOrder: 10 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Advanced',
|
||||
label: 'Advanced',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.Advanced',
|
||||
label: 'Advanced',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Advanced', name: 'Advanced' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Appearance',
|
||||
label: 'Appearance',
|
||||
children: [
|
||||
{
|
||||
key: 'Palette',
|
||||
label: 'Palette',
|
||||
children: [
|
||||
{
|
||||
key: 'Appearance.Palette',
|
||||
label: 'Palette',
|
||||
leaf: true,
|
||||
data: {
|
||||
id: 'Appearance.Palette',
|
||||
name: 'Palette',
|
||||
sortOrder: 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const navGroups = ref([
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
{ id: 'Comfy', label: 'Comfy', icon: 'settings' },
|
||||
{ id: 'Appearance', label: 'Appearance', icon: 'palette' },
|
||||
{ id: 'keybinding', label: 'Keybinding', icon: 'keyboard' },
|
||||
{ id: 'credits', label: 'Credits', icon: 'coins' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const keybindingPanel = {
|
||||
node: { key: 'keybinding', label: 'Keybinding', children: [] },
|
||||
component: defineComponent({
|
||||
name: 'MockKeybindingPanel',
|
||||
setup: () => () => h('div', { 'data-testid': 'keybinding-panel' }, 'keys')
|
||||
})
|
||||
}
|
||||
|
||||
mockSettingUI.refs = {
|
||||
settingCategories,
|
||||
navGroups
|
||||
}
|
||||
|
||||
return {
|
||||
useSettingUI: vi.fn((defaultPanel?: string) => ({
|
||||
defaultCategory: computed(
|
||||
() =>
|
||||
settingCategories.value.find((c) => c.key === defaultPanel) ??
|
||||
settingCategories.value[0]
|
||||
),
|
||||
settingCategories,
|
||||
navGroups,
|
||||
findCategoryByKey: (key: string) =>
|
||||
settingCategories.value.find((c) => c.key === key) ?? null,
|
||||
findPanelByKey: (key: string) =>
|
||||
key === 'keybinding' ? keybindingPanel : null
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingSearch', async () => {
|
||||
const { computed, ref } = await import('vue')
|
||||
|
||||
const searchQuery = ref('')
|
||||
const inSearch = ref(false)
|
||||
const searchResultsCategories = ref(new Set<string>())
|
||||
const matchedNavItemKeys = ref(new Set<string>())
|
||||
const results = ref([
|
||||
{
|
||||
label: 'Search Group',
|
||||
category: 'Comfy',
|
||||
settings: [{ id: 'Comfy.SearchResult', name: 'Search Result' }]
|
||||
}
|
||||
])
|
||||
|
||||
mockSettingSearch.refs = {
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories,
|
||||
matchedNavItemKeys,
|
||||
results
|
||||
}
|
||||
|
||||
mockSettingSearch.handleSearch.mockImplementation(
|
||||
(query: string, navItems: Array<{ key: string; label: string }> = []) => {
|
||||
searchQuery.value = query
|
||||
inSearch.value = query.length > 0
|
||||
searchResultsCategories.value = query.includes('appearance')
|
||||
? new Set(['Appearance'])
|
||||
: new Set()
|
||||
matchedNavItemKeys.value = new Set(
|
||||
navItems
|
||||
.filter((item) => item.label.toLowerCase().includes(query))
|
||||
.map((item) => item.key)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
useSettingSearch: vi.fn(() => ({
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories: computed(() => searchResultsCategories.value),
|
||||
matchedNavItemKeys: computed(() => matchedNavItemKeys.value),
|
||||
handleSearch: mockSettingSearch.handleSearch,
|
||||
getSearchResults: vi.fn(() => results.value)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
function renderDialog(
|
||||
props: Partial<InstanceType<typeof SettingDialog>['$props']> = {}
|
||||
) {
|
||||
return render(SettingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [testI18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SettingDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchBalance.mockReset()
|
||||
if (mockSettingSearch.refs) {
|
||||
mockSettingSearch.refs.searchQuery.value = ''
|
||||
mockSettingSearch.refs.inSearch.value = false
|
||||
mockSettingSearch.refs.searchResultsCategories.value = new Set()
|
||||
mockSettingSearch.refs.matchedNavItemKeys.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the default category panel with sorted groups and settings', () => {
|
||||
renderDialog()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('General')
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('Advanced')
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Comfy\.High.*Comfy\.Low/
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches category from the nav and fetches credits balance for credits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Appearance' }))
|
||||
expect(screen.getByTestId('color-palette-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Credits' }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders panel header slots and disables search autofocus for keybindings', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Keybinding' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('keybinding-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('header')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('header-actions')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('settings-search')).toHaveAttribute(
|
||||
'data-autofocus',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders search results and activates the first matching nav item', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'appearance')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingSearch.handleSearch).toHaveBeenCalledWith(
|
||||
'appearance',
|
||||
expect.arrayContaining([{ key: 'Appearance', label: 'Appearance' }])
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps search mode active when no nav item or category matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the default category after clearing search', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
await user.clear(input)
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('sorts groups by label when group sort order ties', async () => {
|
||||
const refs = mockSettingUI.refs
|
||||
if (!refs) throw new Error('Expected setting UI refs')
|
||||
|
||||
const originalCategories = refs.settingCategories.value
|
||||
const originalNavGroups = refs.navGroups.value
|
||||
refs.settingCategories.value = [
|
||||
...originalCategories,
|
||||
{
|
||||
key: 'Tie',
|
||||
label: 'Tie',
|
||||
children: [
|
||||
{
|
||||
key: 'Beta',
|
||||
label: 'Beta',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Beta',
|
||||
label: 'Beta',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Beta', name: 'Beta', sortOrder: 5 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Alpha',
|
||||
label: 'Alpha',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Alpha',
|
||||
label: 'Alpha',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Alpha', name: 'Alpha', sortOrder: 5 }
|
||||
},
|
||||
{
|
||||
key: 'Tie.NoSort',
|
||||
label: 'NoSort',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.NoSort', name: 'NoSort' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
refs.navGroups.value = [
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
...originalNavGroups[0].items,
|
||||
{ id: 'Tie', label: 'Tie', icon: 'settings' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Tie' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Alpha.*Beta/
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Tie\.Alpha.*Tie\.NoSort/
|
||||
)
|
||||
} finally {
|
||||
refs.settingCategories.value = originalCategories
|
||||
refs.navGroups.value = originalNavGroups
|
||||
}
|
||||
})
|
||||
|
||||
it('scrolls to a target setting and removes its highlight after animation', async () => {
|
||||
const target = document.createElement('div')
|
||||
target.dataset.settingId = 'Comfy.Target'
|
||||
const scrollIntoView = vi.fn()
|
||||
target.scrollIntoView = scrollIntoView
|
||||
document.body.appendChild(target)
|
||||
|
||||
try {
|
||||
renderDialog({ scrollToSettingId: 'Comfy.Target' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
expect(target.classList.contains('setting-highlight')).toBe(true)
|
||||
|
||||
target.dispatchEvent(new Event('animationend'))
|
||||
|
||||
expect(target.classList.contains('setting-highlight')).toBe(false)
|
||||
} finally {
|
||||
target.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
199
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
199
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type SettingValue = boolean | number | string
|
||||
|
||||
// The real canvasStore exposes `canvas` via a shallowRef, so the mock must be
|
||||
// reactive for the composable's watchEffects to re-run when the canvas mounts
|
||||
// after setup. `vi.hoisted` runs before imports, hence the dynamic import.
|
||||
const { canvasStore, settings } = await vi.hoisted(async () => {
|
||||
const { reactive } = await import('vue')
|
||||
return {
|
||||
canvasStore: reactive({
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
show_info?: SettingValue
|
||||
zoom_speed?: SettingValue
|
||||
auto_pan_speed?: SettingValue
|
||||
links_render_mode?: SettingValue
|
||||
min_font_size_for_lod?: SettingValue
|
||||
linkMarkerShape?: SettingValue
|
||||
maximumFps?: SettingValue
|
||||
dragZoomEnabled?: SettingValue
|
||||
liveSelection?: SettingValue
|
||||
groupSelectChildren?: SettingValue
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}),
|
||||
settings: {
|
||||
current: {} as Record<string, SettingValue>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => {
|
||||
class MockCanvasPointer {
|
||||
static doubleClickTime = 0
|
||||
static bufferTime = 0
|
||||
static maxClickDrift = 0
|
||||
}
|
||||
|
||||
class MockLGraphNode {
|
||||
static keepAllLinksOnBypass = false
|
||||
}
|
||||
|
||||
return {
|
||||
CanvasPointer: MockCanvasPointer,
|
||||
LGraphNode: MockLGraphNode,
|
||||
LiteGraph: {
|
||||
Reroute: {},
|
||||
snaps_for_comfy: false,
|
||||
snap_highlights_node: false,
|
||||
middle_click_slot_add_default_node: false,
|
||||
CANVAS_GRID_SIZE: 0,
|
||||
alwaysSnapToGrid: false,
|
||||
context_menu_scaling: 1,
|
||||
canvasNavigationMode: 'legacy',
|
||||
macTrackpadGestures: false,
|
||||
leftMouseClickBehavior: 'select',
|
||||
mouseWheelScroll: 'zoom',
|
||||
saveViewportWithGraph: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings.current[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
function makeCanvas() {
|
||||
return {
|
||||
draw: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings.current = reactive({
|
||||
'Comfy.Graph.CanvasInfo': true,
|
||||
'Comfy.Graph.ZoomSpeed': 1.25,
|
||||
'Comfy.Graph.AutoPanSpeed': 0.75,
|
||||
'Comfy.Node.AutoSnapLinkToSlot': true,
|
||||
'Comfy.Node.SnapHighlightsNode': true,
|
||||
'Comfy.Node.BypassAllLinksOnDelete': true,
|
||||
'Comfy.Node.MiddleClickRerouteNode': true,
|
||||
'Comfy.LinkRenderMode': 2,
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD': 9,
|
||||
'Comfy.Graph.LinkMarkers': 'arrow',
|
||||
'LiteGraph.Canvas.MaximumFps': 42,
|
||||
'Comfy.Graph.CtrlShiftZoom': true,
|
||||
'Comfy.Graph.LiveSelection': true,
|
||||
'Comfy.Pointer.DoubleClickTime': 250,
|
||||
'Comfy.Pointer.ClickBufferTime': 80,
|
||||
'Comfy.Pointer.ClickDrift': 4,
|
||||
'Comfy.SnapToGrid.GridSize': 16,
|
||||
'pysssss.SnapToGrid': true,
|
||||
'LiteGraph.ContextMenu.Scaling': 1.5,
|
||||
'LiteGraph.Reroute.SplineOffset': 32,
|
||||
'Comfy.Canvas.NavigationMode': 'standard',
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning',
|
||||
'Comfy.EnableWorkflowViewRestore': true,
|
||||
'LiteGraph.Group.SelectChildrenOnClick': true
|
||||
})
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
})
|
||||
|
||||
describe('useLitegraphSettings', () => {
|
||||
it('applies canvas settings and marks affected layers dirty', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.auto_pan_speed).toBe(0.75)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.min_font_size_for_lod).toBe(9)
|
||||
expect(canvasStore.canvas?.linkMarkerShape).toBe('arrow')
|
||||
expect(canvasStore.canvas?.maximumFps).toBe(42)
|
||||
expect(canvasStore.canvas?.dragZoomEnabled).toBe(true)
|
||||
expect(canvasStore.canvas?.liveSelection).toBe(true)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(true)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('applies global LiteGraph and pointer settings', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(LiteGraph.snap_highlights_node).toBe(true)
|
||||
expect(LGraphNode.keepAllLinksOnBypass).toBe(true)
|
||||
expect(LiteGraph.middle_click_slot_add_default_node).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
expect(CanvasPointer.bufferTime).toBe(80)
|
||||
expect(CanvasPointer.maxClickDrift).toBe(4)
|
||||
expect(LiteGraph.CANVAS_GRID_SIZE).toBe(16)
|
||||
expect(LiteGraph.alwaysSnapToGrid).toBe(true)
|
||||
expect(LiteGraph.context_menu_scaling).toBe(1.5)
|
||||
expect(LiteGraph.Reroute.maxSplineOffset).toBe(32)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('standard')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(true)
|
||||
expect(LiteGraph.leftMouseClickBehavior).toBe('panning')
|
||||
expect(LiteGraph.mouseWheelScroll).toBe('panning')
|
||||
expect(LiteGraph.saveViewportWithGraph).toBe(true)
|
||||
})
|
||||
|
||||
it('responds when reactive settings change', async () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
settings.current['Comfy.Graph.CanvasInfo'] = false
|
||||
settings.current['Comfy.Canvas.NavigationMode'] = 'custom'
|
||||
settings.current['LiteGraph.Group.SelectChildrenOnClick'] = false
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(false)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(false)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('custom')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(false)
|
||||
})
|
||||
|
||||
it('updates global settings when the canvas is not mounted yet', () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
})
|
||||
|
||||
it('applies canvas settings once the canvas mounts after setup', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
@@ -11,31 +13,68 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function runSettingUI(...args: Parameters<typeof useSettingUI>) {
|
||||
let result!: ReturnType<typeof useSettingUI>
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useSettingUI(...args)
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
}),
|
||||
{ global: { plugins: [testI18n] } }
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
|
||||
auth: { isLoggedIn: { value: false } },
|
||||
billing: {
|
||||
isActiveSubscription: { value: false },
|
||||
type: { value: 'legacy' as const }
|
||||
},
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
|
||||
vueFlags: { shouldRenderVueNodes: { value: false } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billing.isActiveSubscription,
|
||||
type: billing.type
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
flags: featureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -49,6 +88,7 @@ interface MockSettingParams {
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
@@ -72,13 +112,24 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, MockSettingParams>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
auth.isLoggedIn.value = false
|
||||
billing.isActiveSubscription.value = false
|
||||
billing.type.value = 'legacy'
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
featureFlags.teamWorkspacesEnabled = false
|
||||
featureFlags.userSecretsEnabled = false
|
||||
vueFlags.shouldRenderVueNodes.value = false
|
||||
Object.assign(window, { __CONFIG__: {} })
|
||||
|
||||
settingsById = mockSettings
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -98,22 +149,22 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
|
||||
it('defaults to first category when no params are given', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI()
|
||||
const { defaultCategory, settingCategories } = runSettingUI()
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('resolves category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'Appearance.Theme'
|
||||
)
|
||||
@@ -121,12 +172,11 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
it('falls back to first category for unknown scrollToSettingId', () => {
|
||||
const { defaultCategory, settingCategories } = useSettingUI(
|
||||
const { defaultCategory, settingCategories } = runSettingUI(
|
||||
undefined,
|
||||
'NonExistent.Setting'
|
||||
)
|
||||
@@ -134,7 +184,192 @@ describe('useSettingUI', () => {
|
||||
})
|
||||
|
||||
it('gives defaultPanel precedence over scrollToSettingId', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
const { defaultCategory } = runSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('falls back when defaultPanel is not in the menu', () => {
|
||||
const { defaultCategory, settingCategories } = runSettingUI('subscription')
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('moves floating settings into Other and hides Vue-node-only settings', () => {
|
||||
settingsById = {
|
||||
Floating: {
|
||||
id: 'Floating',
|
||||
name: 'Floating',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
'Hidden.Setting': {
|
||||
id: 'Hidden.Setting',
|
||||
name: 'Hidden',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
'Vue.Hidden': {
|
||||
id: 'Vue.Hidden',
|
||||
name: 'Vue Hidden',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
hideInVueNodes: true
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
vueFlags.shouldRenderVueNodes.value = true
|
||||
|
||||
const { settingCategories } = runSettingUI()
|
||||
|
||||
expect(settingCategories.value.map((category) => category.label)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
expect(
|
||||
settingCategories.value[0].children?.map((node) => node.key)
|
||||
).toEqual(['root/Floating'])
|
||||
})
|
||||
|
||||
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
dist.isDesktop = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
|
||||
runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'workspace',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'server-config',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
|
||||
expect(findCategoryByKey('missing')).toBeNull()
|
||||
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
|
||||
expect(findPanelByKey('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds the legacy account menu from auth and subscription gates', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { navGroups, panels } = runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value[0]).toEqual({
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
id: 'user',
|
||||
label: 'User',
|
||||
icon: 'icon-[lucide--user]'
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
},
|
||||
{
|
||||
id: 'secrets',
|
||||
label: 'Secrets',
|
||||
icon: 'icon-[lucide--key-round]'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('includes credits in legacy account settings when login is not subscription-gated', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
dist.isCloud = true
|
||||
|
||||
const { navGroups } = runSettingUI()
|
||||
|
||||
expect(navGroups.value[0].items.map((item) => item.id)).toEqual([
|
||||
'user',
|
||||
'credits'
|
||||
])
|
||||
})
|
||||
|
||||
it('builds workspace menus without optional children when gates are closed', () => {
|
||||
dist.isCloud = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
|
||||
const { navGroups, panels } = runSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(navGroups.value[0].items).toEqual([])
|
||||
})
|
||||
|
||||
it('uses label and fallback icons for custom categories', () => {
|
||||
settingsById = {
|
||||
'Acme.Tools.Toggle': {
|
||||
id: 'Acme.Tools.Toggle',
|
||||
name: 'Toggle',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['Acme Tools', 'Toggles']
|
||||
},
|
||||
PlanSetting: {
|
||||
id: 'PlanSetting',
|
||||
name: 'Plan Setting',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['PlanCredits', 'Credits']
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
const { navGroups } = runSettingUI()
|
||||
const settingsItems = navGroups.value[1].items
|
||||
|
||||
expect(settingsItems).toEqual([
|
||||
{
|
||||
id: 'root/Acme Tools',
|
||||
label: 'Acme Tools',
|
||||
icon: 'icon-[lucide--plug]'
|
||||
},
|
||||
{
|
||||
id: 'root/PlanCredits',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
200
src/platform/settings/constants/coreSettings.test.ts
Normal file
200
src/platform/settings/constants/coreSettings.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
setMany: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/locales/localeConfig', () => ({
|
||||
getDefaultLocale: () => 'en',
|
||||
SUPPORTED_LOCALE_OPTIONS: [{ value: 'en', text: 'English' }]
|
||||
}))
|
||||
|
||||
function setting<T = unknown>(id: string): SettingParams<T> {
|
||||
const result = CORE_SETTINGS.find((item) => item.id === id)
|
||||
if (!result) throw new Error(`Missing setting ${id}`)
|
||||
return result as SettingParams<T>
|
||||
}
|
||||
|
||||
describe('CORE_SETTINGS', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.className = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('uses compact sidebar size below the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1200)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect(typeof defaultValue).toBe('function')
|
||||
expect((defaultValue as () => string)()).toBe('small')
|
||||
})
|
||||
|
||||
it('uses normal sidebar size above the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1600)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect((defaultValue as () => string)()).toBe('normal')
|
||||
})
|
||||
|
||||
it('updates dependent canvas settings when navigation mode changes', async () => {
|
||||
const navigation = setting<string>('Comfy.Canvas.NavigationMode')
|
||||
|
||||
await navigation.onChange?.('standard', 'legacy')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning'
|
||||
})
|
||||
|
||||
await navigation.onChange?.('legacy', 'standard')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'zoom'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not update dependent canvas settings on initial navigation setup', async () => {
|
||||
await setting<string>('Comfy.Canvas.NavigationMode').onChange?.('standard')
|
||||
|
||||
expect(mockSettingStore.setMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when left-click behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when left-click behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'panning'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rewrite custom navigation mode from left-click behavior', async () => {
|
||||
mockSettingStore.get.mockReturnValue('custom')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when wheel behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('zoom')
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when wheel behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('panning')
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the dev-mode API save button when present', () => {
|
||||
const button = document.createElement('button')
|
||||
button.id = 'comfy-dev-save-api-button'
|
||||
document.body.append(button)
|
||||
|
||||
const devMode = setting<boolean>('Comfy.DevMode')
|
||||
devMode.onChange?.(true)
|
||||
expect(button.style.display).toBe('flex')
|
||||
|
||||
devMode.onChange?.(false)
|
||||
expect(button.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores the dev-mode button handler when the element is absent', () => {
|
||||
expect(() =>
|
||||
setting<boolean>('Comfy.DevMode').onChange?.(true)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('toggles the disabled animations body class', () => {
|
||||
const animations = setting<boolean>('Comfy.Appearance.DisableAnimations')
|
||||
|
||||
animations.onChange?.(true)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(true)
|
||||
|
||||
animations.onChange?.(false)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(false)
|
||||
})
|
||||
|
||||
it('migrates deprecated menu and workflow tab values', () => {
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Floating')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Bottom')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Top')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>(
|
||||
'Comfy.Workflow.WorkflowTabsPosition'
|
||||
).migrateDeprecatedValue?.('Topbar (2nd-row)')
|
||||
).toBe('Topbar')
|
||||
})
|
||||
|
||||
it('migrates graph-canvas keybinding target selectors', () => {
|
||||
const bindings = [
|
||||
{
|
||||
combo: { key: 'a' },
|
||||
commandId: 'test.command',
|
||||
targetSelector: '#graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: { key: 'b' },
|
||||
commandId: 'other.command',
|
||||
targetSelector: '#other'
|
||||
}
|
||||
]
|
||||
|
||||
const migrated =
|
||||
setting<Keybinding[]>(
|
||||
'Comfy.Keybinding.UnsetBindings'
|
||||
).migrateDeprecatedValue?.(bindings) ?? []
|
||||
|
||||
expect(migrated[0].targetElementId).toBe('graph-canvas-container')
|
||||
expect(migrated[1].targetElementId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,39 @@ describe('TelemetryRegistry', () => {
|
||||
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
|
||||
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
|
||||
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
|
||||
const b: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const metadata = {
|
||||
user_id: 'user-1',
|
||||
tier: 'pro' as const,
|
||||
cycle: 'monthly' as const,
|
||||
checkout_type: 'new' as const,
|
||||
payment_intent_source: 'subscribe_to_run' as const
|
||||
}
|
||||
registry.trackBeginCheckout(metadata)
|
||||
|
||||
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
|
||||
})
|
||||
|
||||
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
|
||||
const provider: TelemetryProvider = {
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(provider)
|
||||
|
||||
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(
|
||||
provider.trackAddApiCreditButtonClicked
|
||||
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
|
||||
})
|
||||
|
||||
it('skips providers that do not implement trackSearchQuery', () => {
|
||||
const empty: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -99,8 +100,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAddApiCreditButtonClicked?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -313,6 +313,42 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures begin_checkout with intent metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackBeginCheckout({
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.BEGIN_CHECKOUT,
|
||||
{
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('captures add-credit clicks with their source', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'credits_panel' }
|
||||
)
|
||||
})
|
||||
|
||||
it('captures share attribution events', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -10,7 +10,9 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -350,8 +352,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(eventName, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
|
||||
@@ -115,6 +115,17 @@ describe('HostTelemetrySink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards add-credit clicks with their source', () => {
|
||||
new HostTelemetrySink().trackAddApiCreditButtonClicked({
|
||||
source: 'avatar_menu'
|
||||
})
|
||||
|
||||
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'avatar_menu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when the host bridge is absent', () => {
|
||||
delete window.__comfyDesktop2
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -126,8 +127,8 @@ export class HostTelemetrySink implements TelemetryProvider {
|
||||
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -12,12 +12,29 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
export type PaymentIntentSource =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
| 'subscribe_to_run'
|
||||
| 'subscribe_now_button'
|
||||
| 'upgrade_to_add_credits'
|
||||
| 'settings_billing_panel'
|
||||
| 'avatar_menu_plans'
|
||||
| 'team_members_panel'
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
*/
|
||||
@@ -426,16 +443,23 @@ export interface CheckoutAttributionMetadata {
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: SubscriptionDialogReason
|
||||
reason?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export interface AddCreditsClickMetadata {
|
||||
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
extends Record<string, unknown>, CheckoutAttributionMetadata {
|
||||
user_id: string
|
||||
tier: TierKey
|
||||
tier: SubscriptionCheckoutTier
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_attempt_id?: string
|
||||
billing_op_id?: string
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface EcommerceItemMetadata {
|
||||
@@ -457,8 +481,9 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
checkout_attempt_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
value: number
|
||||
currency: string
|
||||
ecommerce: EcommerceMetadata
|
||||
@@ -489,7 +514,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
|
||||
|
||||
@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -336,13 +336,12 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -391,12 +391,13 @@ const showZeroState = computed(
|
||||
)
|
||||
|
||||
function handleSubscribeWorkspace() {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
}
|
||||
|
||||
function handleUpgrade() {
|
||||
if (isFreeTierPlan.value) showPricingTable()
|
||||
else showSubscriptionDialog()
|
||||
if (isFreeTierPlan.value)
|
||||
showPricingTable({ reason: 'settings_billing_panel' })
|
||||
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
}
|
||||
|
||||
function handleViewMoreDetails() {
|
||||
|
||||
@@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
@@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue'
|
||||
|
||||
const { onClose, reason, initialPlanMode } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
reason?: PaymentIntentSource
|
||||
initialPlanMode?: 'personal' | 'team'
|
||||
}>()
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleTeamSubscribe,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit)
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
|
||||
// Backspace mirrors the back arrow on the confirm step, but never while an
|
||||
// editable element is focused (let it delete text there).
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||
|
||||
@@ -17,25 +17,10 @@ const mockHandleResubscribe = vi.fn()
|
||||
const mockHandleSuccessClose = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
useSubscriptionCheckout: mockUseSubscriptionCheckout
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -91,7 +76,7 @@ const SuccessStub = {
|
||||
function renderComponent(
|
||||
props: {
|
||||
onClose?: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
reason?: PaymentIntentSource
|
||||
isPersonal?: boolean
|
||||
} = {}
|
||||
) {
|
||||
@@ -121,6 +106,23 @@ function renderComponent(
|
||||
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSubscriptionCheckout.mockReturnValue({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
})
|
||||
@@ -132,6 +134,15 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the reason into subscription checkout', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'out_of_credits'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the team workspace header by default', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Team Workspace')).toBeInTheDocument()
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
@@ -130,7 +130,7 @@ const {
|
||||
isPersonal = false
|
||||
} = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
reason?: PaymentIntentSource
|
||||
isPersonal?: boolean
|
||||
}>()
|
||||
|
||||
@@ -154,7 +154,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleResubscribe,
|
||||
handleSuccessClose
|
||||
} = useSubscriptionCheckout(emit)
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -61,6 +61,9 @@ function onDismiss() {
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
subscriptionDialog.show({
|
||||
planMode: 'team',
|
||||
reason: 'invite_member_upsell'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function useMembersPanel() {
|
||||
}
|
||||
|
||||
function showTeamPlans() {
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||
@@ -75,7 +76,9 @@ const {
|
||||
mockPlans,
|
||||
mockResubscribe,
|
||||
mockToastAdd,
|
||||
mockStartOperation
|
||||
mockStartOperation,
|
||||
mockTrackBeginCheckout,
|
||||
mockUserId
|
||||
} = vi.hoisted(() => ({
|
||||
mockSubscribe: vi.fn(),
|
||||
mockPreviewSubscribe: vi.fn(),
|
||||
@@ -84,7 +87,9 @@ const {
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockResubscribe: vi.fn(),
|
||||
mockToastAdd: vi.fn(),
|
||||
mockStartOperation: vi.fn()
|
||||
mockStartOperation: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
@@ -119,7 +124,14 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||
useTelemetry: () => ({
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
@@ -135,10 +147,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
describe('useSubscriptionCheckout', () => {
|
||||
let emit: ReturnType<typeof vi.fn>
|
||||
|
||||
async function setup() {
|
||||
async function setup(paymentIntentSource?: PaymentIntentSource) {
|
||||
const { useSubscriptionCheckout } =
|
||||
await import('./useSubscriptionCheckout')
|
||||
return useSubscriptionCheckout(emit as never)
|
||||
return useSubscriptionCheckout(emit as never, paymentIntentSource)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -146,6 +158,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPlans.value = allPlans()
|
||||
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
|
||||
mockUserId.value = 'user-1'
|
||||
emit = vi.fn()
|
||||
})
|
||||
|
||||
@@ -459,6 +472,13 @@ describe('useSubscriptionCheckout', () => {
|
||||
cancelUrl: 'https://platform.comfy.org/payment/failed'
|
||||
})
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-team-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the annual plan slug for the yearly cycle', async () => {
|
||||
@@ -553,6 +573,39 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Team payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps team checkout_type as change when the preview request fails', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
|
||||
await checkout.handleSubscribeTeamClick({
|
||||
stop: {
|
||||
id: 'team_1400',
|
||||
usd: 1400,
|
||||
credits: 295_400,
|
||||
discountedUsd: 1295
|
||||
},
|
||||
billingCycle: 'monthly',
|
||||
isChange: true
|
||||
})
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleTeamSubscribe()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -603,6 +656,47 @@ describe('useSubscriptionCheckout', () => {
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
})
|
||||
|
||||
it('skips begin_checkout when no user id is available', async () => {
|
||||
mockUserId.value = null
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
mockUserId.value = 'user-1'
|
||||
})
|
||||
|
||||
it('fires begin_checkout carrying the payment intent source', async () => {
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'standard',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-1',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
@@ -720,6 +814,7 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,16 +9,26 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType
|
||||
} from '@/platform/telemetry/types'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview' | 'success'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
interface SelectedTeamCheckout {
|
||||
stop: TeamPlanSelection
|
||||
checkoutType: SubscriptionCheckoutType
|
||||
}
|
||||
|
||||
/**
|
||||
* Which screen the `preview` step shows. Only a change prorates: a team change
|
||||
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
|
||||
@@ -45,9 +55,12 @@ export function findPlanSlug(
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
export function useSubscriptionCheckout(emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
}) {
|
||||
export function useSubscriptionCheckout(
|
||||
emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
},
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
@@ -68,13 +81,16 @@ export function useSubscriptionCheckout(emit: {
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
|
||||
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
|
||||
const selectedTeamStop = computed(
|
||||
() => selectedTeamCheckout.value?.stop ?? null
|
||||
)
|
||||
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
|
||||
|
||||
const previewVariant = computed<PreviewVariant>(() => {
|
||||
if (selectedTeamStop.value) {
|
||||
if (selectedTeamCheckout.value) {
|
||||
return previewData.value ? 'team-change' : 'team-new'
|
||||
}
|
||||
if (previewData.value) {
|
||||
@@ -154,7 +170,10 @@ export function useSubscriptionCheckout(emit: {
|
||||
billingCycle: BillingCycle
|
||||
isChange?: boolean
|
||||
}) {
|
||||
selectedTeamStop.value = payload.stop
|
||||
selectedTeamCheckout.value = {
|
||||
stop: payload.stop,
|
||||
checkoutType: payload.isChange ? 'change' : 'new'
|
||||
}
|
||||
selectedBillingCycle.value = payload.billingCycle
|
||||
selectedTierKey.value = null
|
||||
previewData.value = null
|
||||
@@ -182,7 +201,7 @@ export function useSubscriptionCheckout(emit: {
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
selectedTeamStop.value = null
|
||||
selectedTeamCheckout.value = null
|
||||
}
|
||||
|
||||
function handleSuccessClose() {
|
||||
@@ -190,20 +209,34 @@ export function useSubscriptionCheckout(emit: {
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
if (!selectedTierKey.value) return
|
||||
const tierKey = selectedTierKey.value
|
||||
if (!tierKey) return
|
||||
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
const checkoutType =
|
||||
previewData.value &&
|
||||
previewData.value.transition_type !== 'new_subscription'
|
||||
? 'change'
|
||||
: 'new'
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(planSlug, {
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: tierKey,
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
@@ -269,8 +302,8 @@ export function useSubscriptionCheckout(emit: {
|
||||
}
|
||||
|
||||
async function handleTeamSubscription() {
|
||||
const stop = selectedTeamStop.value
|
||||
if (!stop?.id) {
|
||||
const teamCheckout = selectedTeamCheckout.value
|
||||
if (!teamCheckout?.stop.id) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.teamPlan.name'),
|
||||
@@ -279,16 +312,28 @@ export function useSubscriptionCheckout(emit: {
|
||||
return
|
||||
}
|
||||
|
||||
const { stop, checkoutType } = teamCheckout
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
|
||||
const planSlug = getTeamPlanSlug(billingCycle)
|
||||
const response = await subscribe(planSlug, {
|
||||
teamCreditStopId: stop.id,
|
||||
billingCycle: selectedBillingCycle.value,
|
||||
billingCycle,
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
@@ -275,12 +276,12 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show()
|
||||
subscriptionDialog.show({ reason: 'subscription_required' })
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
subscriptionDialog.show(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
38
src/platform/workspace/utils/workspaceCheckoutTelemetry.ts
Normal file
38
src/platform/workspace/utils/workspaceCheckoutTelemetry.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutTier,
|
||||
SubscriptionCheckoutType
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface TrackWorkspaceCheckoutStartedOptions {
|
||||
tier: SubscriptionCheckoutTier
|
||||
cycle: BillingCycle
|
||||
checkoutType: SubscriptionCheckoutType
|
||||
billingOpId: string
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export function trackWorkspaceCheckoutStarted({
|
||||
tier,
|
||||
cycle,
|
||||
checkoutType,
|
||||
billingOpId,
|
||||
paymentIntentSource
|
||||
}: TrackWorkspaceCheckoutStartedOptions) {
|
||||
const { userId } = useAuthStore()
|
||||
if (!userId) return
|
||||
|
||||
useTelemetry()?.trackBeginCheckout({
|
||||
user_id: userId,
|
||||
tier,
|
||||
cycle,
|
||||
checkout_type: checkoutType,
|
||||
billing_op_id: billingOpId,
|
||||
...(paymentIntentSource
|
||||
? { payment_intent_source: paymentIntentSource }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
208
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: overlayMock.overlayMessage,
|
||||
overlayTitle: overlayMock.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
run: 'Run'
|
||||
},
|
||||
menuLabels: {
|
||||
publish: 'Publish'
|
||||
},
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Queueing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing input',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
setActivePinia(pinia)
|
||||
|
||||
useAppModeStore().selectedOutputs = [toNodeId(1)]
|
||||
if (hasError) {
|
||||
useExecutionErrorStore().lastNodeErrors = nodeErrors
|
||||
}
|
||||
|
||||
const toastTarget = document.createElement('div')
|
||||
|
||||
return render(LinearControls, {
|
||||
props: { mobile, toastTo: toastTarget },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
AppModeWidgetList: true,
|
||||
Loader: true,
|
||||
PartnerNodesList: true,
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
ScrubableNumberInput: true,
|
||||
SubscribeToRunButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])('shows a workflow error warning in $label controls', ({ mobile }) => {
|
||||
renderControls({ hasError: true, mobile })
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(
|
||||
within(warning).getByText('Required input missing')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByRole('button', { name: 'Show errors in graph' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: 'Run' })
|
||||
expect(runButton).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without graph errors',
|
||||
({ mobile }) => {
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show errors in graph' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without an active subscription',
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
renderControls({ hasError: true })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -134,9 +148,10 @@ function handleDragDrop() {
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
@@ -166,18 +181,24 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
data-testid="linear-run-button"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
@@ -198,9 +219,14 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing',
|
||||
viewErrorsInGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: mocks.overlayMessage,
|
||||
overlayTitle: mocks.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useViewErrorsInGraph', () => ({
|
||||
useViewErrorsInGraph: () => ({
|
||||
viewErrorsInGraph: mocks.viewErrorsInGraph
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderWarning() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(LinearRunErrorWarning, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearRunErrorWarning', () => {
|
||||
beforeEach(() => {
|
||||
mocks.viewErrorsInGraph.mockReset()
|
||||
})
|
||||
|
||||
it('shows the current error overlay title and message without a close action', () => {
|
||||
renderWarning()
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(warning).toHaveTextContent('Required input missing')
|
||||
expect(warning).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(screen.getByText('Required input missing')).toHaveAttribute(
|
||||
'title',
|
||||
'Required input missing'
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens graph errors when the action is clicked', async () => {
|
||||
const { user } = renderWarning()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show errors in graph' })
|
||||
)
|
||||
|
||||
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
63
src/renderer/extensions/linearMode/LinearRunErrorWarning.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const { overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="linear-validation-warning"
|
||||
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
|
||||
data-testid="linear-validation-warning-description"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
|
||||
:title="overlayTitle"
|
||||
>
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2"
|
||||
data-testid="linear-validation-warning-message"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="linear-view-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{ t('linearMode.error.goto') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
trackNodePrice,
|
||||
usePartitionedBadges
|
||||
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
|
||||
vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
nodeDefs: {} as Record<string, unknown>,
|
||||
pricing: {
|
||||
dynamic: false,
|
||||
widgets: [] as string[],
|
||||
inputs: [] as string[],
|
||||
groups: [] as string[]
|
||||
},
|
||||
getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })),
|
||||
getWidgetMock: vi.fn(() => ({ value: 'widget-value' }))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({
|
||||
getRelevantWidgetNames: () => pricing.widgets,
|
||||
hasDynamicPricing: () => pricing.dynamic,
|
||||
getInputGroupPrefixes: () => pricing.groups,
|
||||
getInputNames: () => pricing.inputs,
|
||||
getNodeRevisionRef: getNodeRevisionRefMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settings[key] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ nodeDefsByName: nodeDefs })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: getWidgetMock })
|
||||
}))
|
||||
|
||||
function nodeData(overrides: Partial<VueNodeData> = {}): VueNodeData {
|
||||
return {
|
||||
executing: false,
|
||||
id: toNodeId(1),
|
||||
mode: 0,
|
||||
selected: false,
|
||||
title: 'Test node',
|
||||
type: 'TestNode',
|
||||
apiNode: false,
|
||||
badges: [],
|
||||
inputs: [],
|
||||
...overrides
|
||||
} satisfies VueNodeData
|
||||
}
|
||||
|
||||
function inputSlot(
|
||||
name: string,
|
||||
readLink: () => number | null
|
||||
): INodeInputSlot {
|
||||
return {
|
||||
name,
|
||||
type: '*',
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
get link() {
|
||||
return readLink()
|
||||
},
|
||||
set link(_value: number | null) {}
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function badge(text: string): LGraphBadge {
|
||||
return new LGraphBadge({ text })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
for (const k of Object.keys(nodeDefs)) delete nodeDefs[k]
|
||||
nodeDefs['TestNode'] = { isCoreNode: false }
|
||||
pricing.dynamic = false
|
||||
pricing.widgets = []
|
||||
pricing.inputs = []
|
||||
pricing.groups = []
|
||||
getNodeRevisionRefMock.mockClear()
|
||||
getWidgetMock.mockClear()
|
||||
})
|
||||
|
||||
describe('usePartitionedBadges', () => {
|
||||
it('emits no core badges when every badge mode is None', () => {
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toEqual([])
|
||||
})
|
||||
|
||||
it('tracks dynamic-pricing dependencies for an api node without throwing', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
apiNode: true,
|
||||
inputs: [
|
||||
inputSlot('model', () => 1),
|
||||
inputSlot('lora.0', () => 2),
|
||||
inputSlot('unrelated', () => null)
|
||||
]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result).toHaveProperty('core')
|
||||
expect(result).toHaveProperty('extension')
|
||||
})
|
||||
|
||||
it('adds an id badge when the id mode is enabled', () => {
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value
|
||||
expect(result.core).toContainEqual({ text: '#7' })
|
||||
})
|
||||
|
||||
it('adds a lifecycle badge, trimmed of brackets', () => {
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: '[BETA]'
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'BETA' })
|
||||
})
|
||||
|
||||
it('adds a source badge for non-core nodes when source mode is on', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeSource: { badgeText: 'my-pack' }
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'my-pack' })
|
||||
})
|
||||
|
||||
it('partitions extension badges (skipping the first) from credits badges', () => {
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge'])
|
||||
expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }])
|
||||
})
|
||||
|
||||
it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = { isCoreNode: true }
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({ badges: [badge('x')] })
|
||||
).value
|
||||
expect(result.hasComfyBadge).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackNodePrice', () => {
|
||||
it('no-ops for a node without dynamic pricing', () => {
|
||||
pricing.dynamic = false
|
||||
trackNodePrice({ id: '1', type: 'Static', inputs: [] })
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1'))
|
||||
expect(getWidgetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touches widget, input, and input-group pricing dependencies', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
let modelReads = 0
|
||||
let groupReads = 0
|
||||
let unrelatedReads = 0
|
||||
|
||||
trackNodePrice({
|
||||
id: '2',
|
||||
type: 'Dynamic',
|
||||
inputs: [
|
||||
inputSlot('model', () => {
|
||||
modelReads += 1
|
||||
return 1
|
||||
}),
|
||||
inputSlot('lora.0', () => {
|
||||
groupReads += 1
|
||||
return 2
|
||||
}),
|
||||
inputSlot('unrelated', () => {
|
||||
unrelatedReads += 1
|
||||
return null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2'))
|
||||
expect(getWidgetMock).toHaveBeenCalled()
|
||||
expect(modelReads).toBe(1)
|
||||
expect(groupReads).toBe(1)
|
||||
expect(unrelatedReads).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from '@/stores/dialogStore'
|
||||
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
// Lazy loaders for dialogs - components are loaded on first use
|
||||
@@ -442,9 +442,9 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showSubscriptionRequiredDialog(options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) {
|
||||
async function showSubscriptionRequiredDialog(
|
||||
options?: SubscriptionDialogOptions
|
||||
) {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
Reference in New Issue
Block a user