mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
3 Commits
v2
...
matt/be-22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9b95473e | ||
|
|
d6c582c399 | ||
|
|
a6db1ab3d6 |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 279 B |
@@ -80,23 +80,19 @@ class HelpCenterHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept the Pylon support URL (and the legacy Zendesk one for safety)
|
||||
* so it never actually loads in the new tab opened by the Contact Support
|
||||
* command.
|
||||
* Intercept the Zendesk support URL so it never actually loads in the
|
||||
* new tab opened by the Contact Support command.
|
||||
*/
|
||||
async stubSupportPage(): Promise<void> {
|
||||
for (const pattern of [
|
||||
'https://comfy-org.portal.usepylon.com/**',
|
||||
'https://support.comfy.org/**'
|
||||
]) {
|
||||
await this.page.context().route(pattern, (route: Route) =>
|
||||
await this.page
|
||||
.context()
|
||||
.route('https://support.comfy.org/**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,14 +103,14 @@ test.describe('Settings', () => {
|
||||
})
|
||||
|
||||
test.describe('Support', () => {
|
||||
test('Should open Pylon question form with OSS environment tag', async ({
|
||||
test('Should open external zendesk link with OSS tag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
// Prevent loading the external page
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
|
||||
.route('https://support.comfy.org/**', (route) =>
|
||||
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
|
||||
)
|
||||
|
||||
@@ -119,9 +119,8 @@ test.describe('Support', () => {
|
||||
const popup = await popupPromise
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
@@ -122,15 +122,9 @@ test.describe('Error dialog', () => {
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('Should open the Pylon bug-report form when "Help Fix This" is clicked', async ({
|
||||
test('Should open contact support when "Help Fix This" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
|
||||
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
|
||||
)
|
||||
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
@@ -139,9 +133,7 @@ test.describe('Error dialog', () => {
|
||||
)
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/report-a-bug')
|
||||
expect(url.searchParams.get('product_area')).toBe('Workflow Error')
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
@@ -99,28 +99,26 @@ test.describe('Help Center', () => {
|
||||
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
|
||||
})
|
||||
|
||||
test('Help & Support item opens the Pylon question form tagged as OSS', async ({
|
||||
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('help').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
})
|
||||
|
||||
test('Give Feedback item opens the Pylon question form in OSS mode', async ({
|
||||
test('Give Feedback item opens Contact Support in OSS mode', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('feedback').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -70,11 +70,10 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { generateErrorReport } from '@/utils/errorReportUtil'
|
||||
import type { ErrorReportData } from '@/utils/errorReportUtil'
|
||||
@@ -116,18 +115,16 @@ const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const { openSupport } = useSupportContext()
|
||||
|
||||
/**
|
||||
* Open contact support flow from error dialog and track telemetry.
|
||||
*/
|
||||
const showContactSupport = () => {
|
||||
const showContactSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +260,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -45,15 +45,14 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const { openSupport } = useSupportContext()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
@@ -71,13 +70,13 @@ const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
openSupport(SupportForm.Billing, { productArea: 'Credits' })
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
|
||||
@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => mockTelemetry
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -68,7 +77,10 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when service throws', async () => {
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,14 +109,15 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -148,10 +154,17 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -165,24 +178,25 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
if (response.total != null) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
if (response.totalPages != null) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +212,12 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -49,13 +49,6 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockOpenSupport = vi.fn()
|
||||
vi.mock('@/platform/support/useSupportContext', () => ({
|
||||
useSupportContext: vi.fn(() => ({
|
||||
openSupport: mockOpenSupport
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
@@ -360,7 +353,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the Pylon bug-report form when Get Help button is clicked', async () => {
|
||||
it('executes ContactSupport command when Get Help button is clicked', async () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -369,9 +362,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
||||
|
||||
expect(mockOpenSupport).toHaveBeenCalledWith('report-a-bug', {
|
||||
productArea: 'Workflow Error'
|
||||
})
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'help_feedback',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useErrorActions } from './useErrorActions'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
openSupport: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
telemetry: null as {
|
||||
trackUiButtonClicked: ReturnType<typeof vi.fn>
|
||||
trackHelpResourceClicked: ReturnType<typeof vi.fn>
|
||||
@@ -15,9 +15,9 @@ const mocks = vi.hoisted(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/support/useSupportContext', () => ({
|
||||
useSupportContext: () => ({
|
||||
openSupport: mocks.openSupport
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mocks.execute
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('useErrorActions', () => {
|
||||
}
|
||||
mocks.trackUiButtonClicked.mockReset()
|
||||
mocks.trackHelpResourceClicked.mockReset()
|
||||
mocks.openSupport.mockReset()
|
||||
mocks.execute.mockReset()
|
||||
windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null as unknown as Window)
|
||||
@@ -84,31 +84,36 @@ describe('useErrorActions', () => {
|
||||
})
|
||||
|
||||
describe('contactSupport', () => {
|
||||
it('tracks the help resource click and opens the Pylon bug-report form', () => {
|
||||
it('tracks the help resource click and executes the contact support command', () => {
|
||||
mocks.execute.mockReturnValue('executed')
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
contactSupport()
|
||||
const result = contactSupport()
|
||||
|
||||
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
|
||||
productArea: 'Workflow Error'
|
||||
})
|
||||
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(result).toBe('executed')
|
||||
})
|
||||
|
||||
it('still opens the support form when telemetry is unavailable', () => {
|
||||
it('returns the execute promise when the command is async', async () => {
|
||||
mocks.execute.mockResolvedValue('done')
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
await expect(contactSupport()).resolves.toBe('done')
|
||||
})
|
||||
|
||||
it('still executes the command when telemetry is unavailable', () => {
|
||||
mocks.telemetry = null
|
||||
const { contactSupport } = useErrorActions()
|
||||
|
||||
contactSupport()
|
||||
void contactSupport()
|
||||
|
||||
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
|
||||
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
|
||||
productArea: 'Workflow Error'
|
||||
})
|
||||
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
export function useErrorActions() {
|
||||
const telemetry = useTelemetry()
|
||||
const { openSupport } = useSupportContext()
|
||||
const commandStore = useCommandStore()
|
||||
const { staticUrls } = useExternalLink()
|
||||
|
||||
function openGitHubIssues() {
|
||||
@@ -22,7 +21,7 @@ export function useErrorActions() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
|
||||
return commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -26,6 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never clobbers the list-derived store when a subscription is absent', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
@@ -18,10 +17,10 @@ import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
@@ -35,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
@@ -96,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
@@ -170,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
// Sync subscription info to workspace store for display in workspace switcher.
|
||||
// Subscribed means active AND not cancelled, so the delete button enables
|
||||
// after cancellation, even before the period ends. A null subscription means
|
||||
// "not loaded yet" (adapters are discarded on every workspace/type switch);
|
||||
// skip it so the transient reinit gap can't clobber the list-derived baseline
|
||||
// (personal workspaces and subscribed teams already read subscribed there).
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
@@ -186,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
|
||||
// init detect that it was superseded (its captured adapter is no longer the
|
||||
// active one), so a stale response can't resolve into a ready state for the
|
||||
// wrong workspace.
|
||||
function resetBillingState() {
|
||||
legacyBillingRef.value = null
|
||||
workspaceBillingRef.value = null
|
||||
isInitialized.value = false
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
resetBillingState()
|
||||
if (!newWorkspaceId) return
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
@@ -216,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const adapter = activeContext.value
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
await adapter.initialize()
|
||||
if (activeContext.value !== adapter) return
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
if (activeContext.value !== adapter) return
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
if (activeContext.value === adapter) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
src/composables/billing/useBillingRouting.test.ts
Normal file
99
src/composables/billing/useBillingRouting.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const personal = { id: 'w-personal', type: 'personal' as const }
|
||||
const team = { id: 'w-team', type: 'team' as const }
|
||||
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
it('uses legacy billing when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
})
|
||||
36
src/composables/billing/useBillingRouting.ts
Normal file
36
src/composables/billing/useBillingRouting.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type { BillingType } from './types'
|
||||
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
|
||||
// An unloaded workspace has no type yet; stay legacy so bootstrap never
|
||||
// eagerly routes to workspace billing.
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
return 'workspace'
|
||||
})
|
||||
|
||||
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
|
||||
|
||||
return { type, shouldUseWorkspaceBilling }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
@@ -22,8 +23,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -864,7 +864,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Contact Support',
|
||||
versionAdded: '1.17.8',
|
||||
function: () => {
|
||||
useSupportContext().openSupport(SupportForm.Question)
|
||||
const { userEmail, resolvedUserInfo } = useCurrentUser()
|
||||
const supportUrl = buildSupportUrl({
|
||||
userEmail: userEmail.value,
|
||||
userId: resolvedUserInfo.value?.id
|
||||
})
|
||||
window.open(supportUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
useFeatureFlags
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Mock the API module
|
||||
@@ -219,6 +225,86 @@ describe('useFeatureFlags', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-gated flags on cloud', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {}
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -46,6 +49,26 @@ function resolveFlag<T>(
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
|
||||
* Cloud build it is always false; during the auth window it falls back to the
|
||||
* cached session value so anonymous bootstrap config cannot route the user to
|
||||
* the wrong backend before authenticated config confirms the flag.
|
||||
*/
|
||||
function resolveAuthGatedFlag(
|
||||
flagKey: string,
|
||||
remoteConfigValue: boolean | undefined,
|
||||
cachedValue: Ref<boolean | undefined>
|
||||
): boolean {
|
||||
const override = getDevOverride<boolean>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
|
||||
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
|
||||
remoteConfig.value.team_workspaces_enabled,
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
*/
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
|
||||
@@ -2484,6 +2484,8 @@
|
||||
"model": "Model",
|
||||
"added": "Added",
|
||||
"accountInitialized": "Account initialized",
|
||||
"loadEventsError": "Failed to load activity. Please try again.",
|
||||
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
|
||||
"eventTypes": {
|
||||
"creditAdded": "Credits Added",
|
||||
"accountCreated": "Account Created",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<p class="mb-5 text-center text-sm text-gray-600">
|
||||
{{ $t('cloudOnboarding.authTimeout.helpText') }}
|
||||
<a
|
||||
:href="supportUrl"
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -75,7 +75,6 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
|
||||
|
||||
interface Props {
|
||||
errorMessage?: string
|
||||
@@ -87,10 +86,6 @@ const router = useRouter()
|
||||
const { logout } = useAuthActions()
|
||||
const showTechnicalDetails = ref(false)
|
||||
|
||||
const supportUrl = buildSupportUrl(SupportForm.Question, {
|
||||
productArea: 'Cloud Onboarding'
|
||||
})
|
||||
|
||||
const handleRestart = async () => {
|
||||
await logout()
|
||||
await router.replace({ name: 'cloud-login' })
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
>
|
||||
{{ t('cloudWaitlist_questionsText') }}
|
||||
<a
|
||||
:href="supportUrl"
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -136,7 +136,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
|
||||
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
@@ -163,10 +162,6 @@ const { onAuthSuccess } = usePostAuthRedirect({
|
||||
defaultRedirect: () => ({ path: '/', query: route.query })
|
||||
})
|
||||
|
||||
const supportUrl = buildSupportUrl(SupportForm.Question, {
|
||||
productArea: 'Cloud Onboarding'
|
||||
})
|
||||
|
||||
const navigateToLogin = async () => {
|
||||
await router.push({ name: 'cloud-login', query: route.query })
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ t('auth.login.privacyLink') }}
|
||||
</a>
|
||||
<a
|
||||
:href="supportUrl"
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-sm text-gray-600 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -30,11 +30,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const supportUrl = buildSupportUrl(SupportForm.Question, {
|
||||
productArea: 'Cloud Onboarding'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
|
||||
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
|
||||
<!-- Legacy mode: user-level subscription content -->
|
||||
<template v-else>
|
||||
<SubscriptionPanelContentLegacy />
|
||||
@@ -29,24 +29,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
|
||||
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
</script>
|
||||
|
||||
@@ -159,9 +159,8 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
const { onClose, reason, onChooseTeam } = defineProps<{
|
||||
@@ -189,7 +188,7 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(MONTHLY_SUBSCRIPTION_PRICE)
|
||||
const { openSupport } = useSupportContext()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Always show custom pricing table for cloud subscriptions
|
||||
@@ -220,13 +219,13 @@ const handleClose = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleContactUs = () => {
|
||||
const handleContactUs = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
openSupport(SupportForm.Billing, { productArea: 'Billing' })
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleViewEnterprise = () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ const mockBillingFetchBalance = vi.fn()
|
||||
const mockAuthFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
const mockOpenSupport = vi.fn()
|
||||
const mockExecute = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
@@ -32,14 +32,13 @@ vi.mock('@/services/dialogService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/support/useSupportContext', () => ({
|
||||
useSupportContext: () => ({
|
||||
openSupport: mockOpenSupport
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mockExecute
|
||||
})
|
||||
}))
|
||||
|
||||
// mockIsCloud drives both the `isCloud` build flag (which gates the telemetry
|
||||
// call) and useTelemetry() (which returns null in OSS, a dispatcher in cloud).
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const {
|
||||
mockIsCloud,
|
||||
mockTrackHelpResourceClicked,
|
||||
@@ -60,14 +59,6 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
: null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: false,
|
||||
isNightly: false,
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const mockOpen = vi.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
@@ -93,24 +84,24 @@ describe('useSubscriptionActions', () => {
|
||||
})
|
||||
|
||||
describe('handleMessageSupport', () => {
|
||||
it('opens the Pylon billing form and resets loading state', () => {
|
||||
it('should execute support command and manage loading state', async () => {
|
||||
const { handleMessageSupport, isLoadingSupport } =
|
||||
useSubscriptionActions()
|
||||
|
||||
expect(isLoadingSupport.value).toBe(false)
|
||||
|
||||
handleMessageSupport()
|
||||
const promise = handleMessageSupport()
|
||||
expect(isLoadingSupport.value).toBe(true)
|
||||
|
||||
expect(mockOpenSupport).toHaveBeenCalledWith('billing-refund-issue', {
|
||||
productArea: 'Billing'
|
||||
})
|
||||
await promise
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(isLoadingSupport.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks help-resource telemetry when messaging support in cloud', () => {
|
||||
it('tracks help-resource telemetry when messaging support in cloud', async () => {
|
||||
const { handleMessageSupport } = useSubscriptionActions()
|
||||
|
||||
handleMessageSupport()
|
||||
await handleMessageSupport()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
@@ -119,23 +110,21 @@ describe('useSubscriptionActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire telemetry when messaging support in OSS builds', () => {
|
||||
it('does not fire telemetry when messaging support in OSS builds', async () => {
|
||||
mockIsCloud.value = false
|
||||
const { handleMessageSupport } = useSubscriptionActions()
|
||||
|
||||
handleMessageSupport()
|
||||
await handleMessageSupport()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
mockOpenSupport.mockImplementationOnce(() => {
|
||||
throw new Error('open failed')
|
||||
})
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
|
||||
const { handleMessageSupport, isLoadingSupport } =
|
||||
useSubscriptionActions()
|
||||
|
||||
handleMessageSupport()
|
||||
await handleMessageSupport()
|
||||
expect(isLoadingSupport.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { SupportForm } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription panel actions and loading states
|
||||
*/
|
||||
export function useSubscriptionActions() {
|
||||
const dialogService = useDialogService()
|
||||
const { openSupport } = useSupportContext()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { fetchBalance, fetchStatus } = useBillingContext()
|
||||
|
||||
@@ -29,17 +27,15 @@ export function useSubscriptionActions() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
const handleMessageSupport = async () => {
|
||||
try {
|
||||
isLoadingSupport.value = true
|
||||
if (isCloud) {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
}
|
||||
openSupport(SupportForm.Billing, { productArea: 'Billing' })
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
} catch (error) {
|
||||
console.error('[useSubscriptionActions] Error contacting support:', error)
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
|
||||
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
|
||||
@@ -35,12 +35,10 @@ vi.mock('@/services/dialogService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
vi.mock('@/composables/billing/useBillingRouting', () => ({
|
||||
useBillingRouting: () => ({
|
||||
get shouldUseWorkspaceBilling() {
|
||||
return mockShouldUseWorkspaceBilling
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -88,7 +86,7 @@ describe('useSubscriptionDialog', () => {
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTier.value = 'FREE'
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockShouldUseWorkspaceBilling.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
mockCanManageSubscription.value = true
|
||||
|
||||
@@ -119,7 +117,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -131,7 +129,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -146,7 +144,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('defaults to the personal tab in a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -157,7 +155,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('opens the team tab when planMode is forced from a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -167,8 +165,9 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
|
||||
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
it('uses the legacy table (with onChooseTeam) on the legacy billing flow', () => {
|
||||
mockShouldUseWorkspaceBilling.value = false
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
@@ -178,7 +177,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsLegacyTeamPlan.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -196,7 +195,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -220,7 +219,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('tracks modal_opened on the workspace (unified) path too', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
@@ -232,7 +231,7 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
|
||||
it('does not track modal_opened for the inactive member dialog', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
@@ -24,7 +24,7 @@ export interface SubscriptionDialogOptions {
|
||||
}
|
||||
|
||||
export const useSubscriptionDialog = () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
@@ -57,7 +57,7 @@ export const useSubscriptionDialog = () => {
|
||||
// small read-only "ask your owner to reactivate" modal instead of the
|
||||
// pricing table. Out-of-credits still routes everyone to the credits flow.
|
||||
if (
|
||||
flags.teamWorkspacesEnabled &&
|
||||
shouldUseWorkspaceBilling.value &&
|
||||
!workspaceStore.isInPersonalWorkspace &&
|
||||
!permissions.value.canManageSubscription &&
|
||||
options?.reason !== 'out_of_credits'
|
||||
@@ -95,9 +95,10 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) when team workspaces are enabled. Replaces the old
|
||||
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
// one workspace) for workspaces on the consolidated billing flow. Replaces
|
||||
// the old personal-vs-team workspace fork. Personal workspaces still on the
|
||||
// legacy flow (consolidated billing disabled) get the legacy table.
|
||||
if (shouldUseWorkspaceBilling.value) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
// Resolved lazily (not at composable setup): these three composables form
|
||||
|
||||
113
src/platform/missing/missingCandidateHelpers.test.ts
Normal file
113
src/platform/missing/missingCandidateHelpers.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from './missingCandidateHelpers'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rootGraph: null as unknown,
|
||||
getActiveGraphNodeIds: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mocks.rootGraph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getActiveGraphNodeIds: mocks.getActiveGraphNodeIds
|
||||
}))
|
||||
|
||||
describe('createVerificationAbortController', () => {
|
||||
it('create returns a fresh, non-aborted controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const controller = manager.create()
|
||||
expect(controller.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('create aborts the previously issued controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const first = manager.create()
|
||||
manager.create()
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('abort aborts the current controller', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
const controller = manager.create()
|
||||
manager.abort()
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('abort after abort is a no-op (no current controller)', () => {
|
||||
const manager = createVerificationAbortController()
|
||||
manager.create()
|
||||
manager.abort()
|
||||
expect(() => manager.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAncestorExecutionIds', () => {
|
||||
it('expands each node id into its execution-id prefixes, inclusive', () => {
|
||||
const result = computeAncestorExecutionIds(['65:70:63'])
|
||||
expect([...result]).toEqual(['65', '65:70', '65:70:63'])
|
||||
})
|
||||
|
||||
it('deduplicates shared ancestor prefixes across node ids', () => {
|
||||
const result = computeAncestorExecutionIds(['65:70', '65:71'])
|
||||
expect([...result]).toEqual(['65', '65:70', '65:71'])
|
||||
})
|
||||
|
||||
it('returns an empty set for no node ids', () => {
|
||||
expect(computeAncestorExecutionIds([]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeActiveGraphIds', () => {
|
||||
beforeEach(() => {
|
||||
mocks.rootGraph = null
|
||||
mocks.getActiveGraphNodeIds.mockReset()
|
||||
})
|
||||
|
||||
it('returns an empty set when the root graph is unavailable', () => {
|
||||
const result = computeActiveGraphIds(null, new Set())
|
||||
expect(result.size).toBe(0)
|
||||
expect(mocks.getActiveGraphNodeIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates to getActiveGraphNodeIds with the current graph', () => {
|
||||
const rootGraph = { id: 'root' }
|
||||
const currentGraph = { id: 'current' }
|
||||
mocks.rootGraph = rootGraph
|
||||
mocks.getActiveGraphNodeIds.mockReturnValue(new Set(['1']))
|
||||
|
||||
const ancestors = computeAncestorExecutionIds(['65'])
|
||||
const result = computeActiveGraphIds(currentGraph as never, ancestors)
|
||||
|
||||
expect(result).toEqual(new Set(['1']))
|
||||
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
currentGraph,
|
||||
ancestors
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the root graph when no current graph is given', () => {
|
||||
const rootGraph = { id: 'root' }
|
||||
mocks.rootGraph = rootGraph
|
||||
mocks.getActiveGraphNodeIds.mockReturnValue(new Set<string>())
|
||||
|
||||
computeActiveGraphIds(null, new Set())
|
||||
|
||||
expect(mocks.getActiveGraphNodeIds).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
rootGraph,
|
||||
expect.any(Set)
|
||||
)
|
||||
})
|
||||
})
|
||||
55
src/platform/missing/missingCandidateHelpers.ts
Normal file
55
src/platform/missing/missingCandidateHelpers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface VerificationAbortController {
|
||||
create(): AbortController
|
||||
abort(): void
|
||||
}
|
||||
|
||||
export function createVerificationAbortController(): VerificationAbortController {
|
||||
let controller: AbortController | null = null
|
||||
return {
|
||||
create() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
return controller
|
||||
},
|
||||
abort() {
|
||||
controller?.abort()
|
||||
controller = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from the given node IDs,
|
||||
* including the nodes themselves.
|
||||
*
|
||||
* Example: node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
export function computeAncestorExecutionIds(
|
||||
nodeIds: Iterable<string>
|
||||
): Set<NodeExecutionId> {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of nodeIds) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function computeActiveGraphIds(
|
||||
currentGraph: LGraph | null,
|
||||
ancestorExecutionIds: Set<NodeExecutionId>
|
||||
): Set<string> {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
currentGraph ?? app.rootGraph,
|
||||
ancestorExecutionIds
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { computed, ref } from 'vue'
|
||||
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from '@/platform/missing/missingCandidateHelpers'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
@@ -31,38 +32,18 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
|
||||
)
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing media node IDs,
|
||||
* including the missing media nodes themselves.
|
||||
*/
|
||||
const missingMediaAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingMediaNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
const missingMediaAncestorExecutionIds = computed(() =>
|
||||
computeAncestorExecutionIds(missingMediaNodeIds.value)
|
||||
)
|
||||
|
||||
const activeMissingMediaGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
const activeMissingMediaGraphIds = computed(() =>
|
||||
computeActiveGraphIds(
|
||||
canvasStore.currentGraph,
|
||||
missingMediaAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
const verificationAbortController = createVerificationAbortController()
|
||||
|
||||
function setMissingMedia(media: MissingMediaCandidate[]) {
|
||||
missingMediaCandidates.value = media.length ? media : null
|
||||
@@ -132,8 +113,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
}
|
||||
|
||||
function clearMissingMedia() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
verificationAbortController.abort()
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
@@ -151,7 +131,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
removeMissingMediaByNodeId,
|
||||
removeMissingMediaByPrefix,
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
createVerificationAbortController: verificationAbortController.create,
|
||||
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
computeActiveGraphIds,
|
||||
computeAncestorExecutionIds,
|
||||
createVerificationAbortController
|
||||
} from '@/platform/missing/missingCandidateHelpers'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
/**
|
||||
* Missing model error state and interaction state.
|
||||
@@ -51,32 +54,16 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
return keys
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing model node IDs,
|
||||
* including the missing model nodes themselves.
|
||||
*
|
||||
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingModelNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
const missingModelAncestorExecutionIds = computed(() =>
|
||||
computeAncestorExecutionIds(missingModelNodeIds.value)
|
||||
)
|
||||
|
||||
const activeMissingModelGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
const activeMissingModelGraphIds = computed(() =>
|
||||
computeActiveGraphIds(
|
||||
canvasStore.currentGraph,
|
||||
missingModelAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Persists across component re-mounts so that download progress
|
||||
// survives tab switches within the right-side panel.
|
||||
@@ -86,13 +73,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const folderPaths = ref<Record<string, string[]>>({})
|
||||
const fileSizes = ref<Record<string, number>>({})
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
const verificationAbortController = createVerificationAbortController()
|
||||
|
||||
function setMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelCandidates.value = models.length ? models : null
|
||||
@@ -246,8 +227,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
}
|
||||
|
||||
function clearMissingModels() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
verificationAbortController.abort()
|
||||
missingModelCandidates.value = null
|
||||
modelExpandState.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
@@ -298,7 +278,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
removeMissingModelsBySourceScope,
|
||||
clearMissingModels,
|
||||
refreshMissingModels,
|
||||
createVerificationAbortController,
|
||||
createVerificationAbortController: verificationAbortController.create,
|
||||
|
||||
hasMissingModelOnNode,
|
||||
isWidgetMissingModel,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -55,10 +56,14 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = config
|
||||
remoteConfig.value = config
|
||||
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
|
||||
if (useAuth)
|
||||
if (useAuth) {
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
cachedConsolidatedBillingEnabled.value = Boolean(
|
||||
config.consolidated_billing_enabled
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -59,3 +59,8 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
|
||||
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -111,6 +111,7 @@ export type RemoteConfig = {
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -11,21 +11,49 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
const env = vi.hoisted(() => {
|
||||
const state = {
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isLoggedIn: false,
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
return state[key]
|
||||
}
|
||||
})
|
||||
return { state, fakeRef }
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: env.fakeRef('isActiveSubscription'),
|
||||
type: env.fakeRef('billingType')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return env.state.teamWorkspacesEnabled
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return env.state.userSecretsEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -34,8 +62,12 @@ vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
get isCloud() {
|
||||
return env.state.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return env.state.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -77,6 +109,16 @@ describe('useSettingUI', () => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
Object.assign(env.state, {
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isLoggedIn: false,
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
})
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
@@ -137,4 +179,59 @@ describe('useSettingUI', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
describe('legacy billing in the workspace layout', () => {
|
||||
const navKeys = (groups: { items: { id: string }[] }[]) =>
|
||||
groups.flatMap((group) => group.items.map((item) => item.id))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(env.state, {
|
||||
isCloud: true,
|
||||
isLoggedIn: true,
|
||||
teamWorkspacesEnabled: true,
|
||||
isActiveSubscription: true
|
||||
})
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
it('exposes the legacy plan panel when billing is legacy', () => {
|
||||
env.state.billingType = 'legacy'
|
||||
const { defaultCategory, navGroups } = useSettingUI('subscription')
|
||||
|
||||
expect(defaultCategory.value.key).toBe('subscription')
|
||||
expect(navKeys(navGroups.value)).toContain('subscription')
|
||||
expect(navKeys(navGroups.value)).toContain('workspace')
|
||||
})
|
||||
|
||||
it('hides the legacy plan panel when billing is workspace', () => {
|
||||
env.state.billingType = 'workspace'
|
||||
const { navGroups } = useSettingUI()
|
||||
|
||||
expect(navKeys(navGroups.value)).not.toContain('subscription')
|
||||
expect(navKeys(navGroups.value)).toContain('workspace')
|
||||
})
|
||||
|
||||
it('never renders the plan panel in more than one tab', () => {
|
||||
const countSubscription = () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
return navKeys(navGroups.value).filter((id) => id === 'subscription')
|
||||
.length
|
||||
}
|
||||
|
||||
for (const teamWorkspacesEnabled of [true, false]) {
|
||||
for (const billingType of ['legacy', 'workspace'] as const) {
|
||||
for (const isLoggedIn of [true, false]) {
|
||||
Object.assign(env.state, {
|
||||
teamWorkspacesEnabled,
|
||||
billingType,
|
||||
isLoggedIn
|
||||
})
|
||||
expect(countSubscription()).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useSettingUI(
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { isActiveSubscription, type: billingType } = useBillingContext()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -157,6 +157,13 @@ export function useSettingUI(
|
||||
return isActiveSubscription.value
|
||||
})
|
||||
|
||||
const shouldShowLegacyPlanCreditsPanel = computed(
|
||||
() =>
|
||||
isLoggedIn.value &&
|
||||
billingType.value === 'legacy' &&
|
||||
shouldShowPlanCreditsPanel.value
|
||||
)
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
@@ -301,6 +308,9 @@ export function useSettingUI(
|
||||
label: 'General',
|
||||
children: [
|
||||
translateCategory(userPanel.node),
|
||||
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
|
||||
? [translateCategory(subscriptionPanel.node)]
|
||||
: []),
|
||||
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
|
||||
...(shouldShowSecretsPanel.value
|
||||
? [translateCategory(secretsPanel.node)]
|
||||
@@ -332,9 +342,7 @@ export function useSettingUI(
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value &&
|
||||
shouldShowPlanCreditsPanel.value &&
|
||||
subscriptionPanel
|
||||
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),
|
||||
|
||||
@@ -50,184 +50,3 @@ describe('buildFeedbackTypeformUrl', () => {
|
||||
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSupportUrl', () => {
|
||||
const ORIGINAL_UA = navigator.userAgent
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
value: ORIGINAL_UA,
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
function setUserAgent(value: string) {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
value,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
async function importModule() {
|
||||
vi.resetModules()
|
||||
return import('./config')
|
||||
}
|
||||
|
||||
it('defaults to the question form when no form is provided', async () => {
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
})
|
||||
|
||||
it('routes to the requested form slug', async () => {
|
||||
const { buildSupportUrl, SupportForm } = await importModule()
|
||||
const url = new URL(buildSupportUrl(SupportForm.Billing))
|
||||
expect(url.pathname).toBe('/forms/billing-refund-issue')
|
||||
})
|
||||
|
||||
it('encodes spaces as %20 (not "+") in the query string', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0'
|
||||
)
|
||||
const { buildSupportUrl, SupportForm } = await importModule()
|
||||
const raw = buildSupportUrl(SupportForm.Bug, {
|
||||
userEmail: 'user@example.com',
|
||||
os: 'macOS 14.5'
|
||||
})
|
||||
expect(raw).toContain('comfy_os=macOS%2014.5')
|
||||
expect(raw).not.toContain('+')
|
||||
})
|
||||
|
||||
it('omits fields with empty or null values', async () => {
|
||||
const { buildSupportUrl, SupportForm } = await importModule()
|
||||
const url = new URL(
|
||||
buildSupportUrl(SupportForm.Question, {
|
||||
userEmail: '',
|
||||
userId: null,
|
||||
os: undefined,
|
||||
version: '1.45.0'
|
||||
})
|
||||
)
|
||||
expect(url.searchParams.has('email')).toBe(false)
|
||||
expect(url.searchParams.has('comfy_cloud_user_id')).toBe(false)
|
||||
expect(url.searchParams.has('comfy_os')).toBe(false)
|
||||
expect(url.searchParams.get('comfy_version')).toBe('1.45.0')
|
||||
})
|
||||
|
||||
it('tags Cloud builds with comfy_environment=ccloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('ccloud')
|
||||
})
|
||||
|
||||
it('tags Nightly builds with comfy_environment=oss-nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss-nightly')
|
||||
})
|
||||
|
||||
it('detects Chrome from the user agent', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
)
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.searchParams.get('browser')).toBe('Chrome 131')
|
||||
})
|
||||
|
||||
it('detects Firefox from the user agent', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'
|
||||
)
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.searchParams.get('browser')).toBe('Firefox 121')
|
||||
})
|
||||
|
||||
it('detects Edge before falling through to Chrome', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'
|
||||
)
|
||||
const { buildSupportUrl } = await importModule()
|
||||
const url = new URL(buildSupportUrl())
|
||||
expect(url.searchParams.get('browser')).toBe('Edge 131')
|
||||
})
|
||||
|
||||
it('forwards a product area override to the prefill', async () => {
|
||||
const { buildSupportUrl, SupportForm } = await importModule()
|
||||
const url = new URL(
|
||||
buildSupportUrl(SupportForm.Billing, { productArea: 'Billing' })
|
||||
)
|
||||
expect(url.searchParams.get('product_area')).toBe('Billing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeOsName', () => {
|
||||
const ORIGINAL_UA = navigator.userAgent
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
value: ORIGINAL_UA,
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
function setUserAgent(value: string) {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
value,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
async function importModule() {
|
||||
vi.resetModules()
|
||||
return import('./config')
|
||||
}
|
||||
|
||||
it('promotes "darwin" to the UA-detected macOS version', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
|
||||
)
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName('darwin')).toBe('macOS 14.5.0')
|
||||
})
|
||||
|
||||
it('promotes "win32" to the UA-detected Windows version', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
|
||||
)
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName('win32')).toBe('Windows 10/11')
|
||||
})
|
||||
|
||||
it('promotes "linux" to "Linux" when UA reports Linux', async () => {
|
||||
setUserAgent('Mozilla/5.0 (X11; Linux x86_64) Firefox/121.0')
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName('linux')).toBe('Linux')
|
||||
})
|
||||
|
||||
it('keeps a descriptive value untouched', async () => {
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName('Ubuntu 22.04')).toBe('Ubuntu 22.04')
|
||||
})
|
||||
|
||||
it('falls back to UA detection when the input is empty', async () => {
|
||||
setUserAgent(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0 Safari/537.36'
|
||||
)
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName(null)).toBe('macOS 10.15.7')
|
||||
expect(normalizeOsName('')).toBe('macOS 10.15.7')
|
||||
})
|
||||
|
||||
it('falls back to the kernel name when UA detection cannot resolve', async () => {
|
||||
setUserAgent('SomeWeirdBot/1.0')
|
||||
const { normalizeOsName } = await importModule()
|
||||
expect(normalizeOsName('darwin')).toBe('darwin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,189 +1,70 @@
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
/**
|
||||
* Slug of a Pylon form under https://comfy-org.portal.usepylon.com/forms/.
|
||||
* The form slug determines which ticket form opens and which fields are shown.
|
||||
* Zendesk ticket form field IDs.
|
||||
*/
|
||||
export const SupportForm = {
|
||||
Billing: 'billing-refund-issue',
|
||||
Bug: 'report-a-bug',
|
||||
FeatureRequest: 'feature-request',
|
||||
PartnerNode: 'partner-node-issue',
|
||||
Question: 'question'
|
||||
} as const
|
||||
export type SupportForm = (typeof SupportForm)[keyof typeof SupportForm]
|
||||
|
||||
/**
|
||||
* Pylon custom-field slugs (URL keys) configured for the comfy-org workspace.
|
||||
* Pylon prefill uses the slug — not the field UUID — as the URL key.
|
||||
*/
|
||||
const PYLON_FIELDS = {
|
||||
EMAIL: 'email',
|
||||
BROWSER: 'browser',
|
||||
COMFY_CLOUD_USER_ID: 'comfy_cloud_user_id',
|
||||
COMFY_ENVIRONMENT: 'comfy_environment',
|
||||
COMFY_OS: 'comfy_os',
|
||||
COMFY_VERSION: 'comfy_version',
|
||||
PRODUCT_AREA: 'product_area'
|
||||
const ZENDESK_FIELDS = {
|
||||
/** Distribution tag (cloud vs OSS) */
|
||||
DISTRIBUTION: 'tf_42243568391700',
|
||||
/** User email (anonymous requester) */
|
||||
ANONYMOUS_EMAIL: 'tf_anonymous_requester_email',
|
||||
/** User email (authenticated) */
|
||||
EMAIL: 'tf_40029135130388',
|
||||
/** User ID */
|
||||
USER_ID: 'tf_42515251051412'
|
||||
} as const
|
||||
|
||||
const PYLON_FORMS_BASE_URL = 'https://comfy-org.portal.usepylon.com/forms/'
|
||||
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
/**
|
||||
* Build environment tag for distinguishing tickets by build type.
|
||||
* Gets the distribution identifier for tracking.
|
||||
* Helps distinguish feedback from different build types.
|
||||
*/
|
||||
function getEnvironment(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
if (isCloud) return 'ccloud'
|
||||
if (isNightly) return 'oss-nightly'
|
||||
return 'oss'
|
||||
}
|
||||
|
||||
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
/**
|
||||
* Builds the feedback Typeform URL tagged with the current build environment
|
||||
* Builds the feedback Typeform URL tagged with the current build distribution
|
||||
* and the UI source that opened it. Tags are passed via the URL fragment
|
||||
* (Typeform's hidden-field convention) so survey responses can be segmented
|
||||
* by environment (cloud / oss-nightly / oss) and entry point.
|
||||
* by distribution (cloud / oss-nightly / oss) and entry point.
|
||||
*/
|
||||
export function buildFeedbackTypeformUrl(
|
||||
source: 'topbar' | 'action-bar' | 'help-center'
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
distribution: getEnvironment(),
|
||||
distribution: getDistribution(),
|
||||
source
|
||||
})
|
||||
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
|
||||
}
|
||||
|
||||
export interface SupportPrefill {
|
||||
/** Authenticated user's email (for Cloud / API-key users). */
|
||||
userEmail?: string | null
|
||||
/** Cloud user id, when available. */
|
||||
userId?: string | null
|
||||
/** Operating system string (e.g. "macOS 14.5"). */
|
||||
os?: string | null
|
||||
/** ComfyUI frontend version. */
|
||||
version?: string | null
|
||||
/** Product area this ticket belongs to (e.g. "Billing", "Cloud"). */
|
||||
productArea?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a single `slug=value` pair. Skips empty values so the resulting URL
|
||||
* stays clean. We use `encodeURIComponent` (not `URLSearchParams`) so spaces
|
||||
* become `%20` rather than `+`, matching the Pylon prefill spec.
|
||||
*/
|
||||
function encodePair(
|
||||
slug: string,
|
||||
value: string | null | undefined
|
||||
): string | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return `${encodeURIComponent(slug)}=${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
function detectBrowser(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const ua = navigator.userAgent
|
||||
// Order matters: Edge / Opera identify themselves as Chrome too.
|
||||
const matchers: { name: string; pattern: RegExp }[] = [
|
||||
{ name: 'Edge', pattern: /Edg\/([\d.]+)/ },
|
||||
{ name: 'Opera', pattern: /OPR\/([\d.]+)/ },
|
||||
{ name: 'Chrome', pattern: /Chrome\/([\d.]+)/ },
|
||||
{ name: 'Firefox', pattern: /Firefox\/([\d.]+)/ },
|
||||
{ name: 'Safari', pattern: /Version\/([\d.]+).*Safari/ }
|
||||
]
|
||||
for (const { name, pattern } of matchers) {
|
||||
const match = ua.match(pattern)
|
||||
if (match) return `${name} ${match[1].split('.')[0]}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a user-friendly OS string from the browser. Preferred over backend
|
||||
* platform names like `darwin` / `win32` because those are kernel identifiers,
|
||||
* not what users (or support agents) recognize. Modern browsers freeze the
|
||||
* macOS / Windows minor version in the UA string, so we only report the
|
||||
* family — that's still more useful than `darwin`.
|
||||
*/
|
||||
export function detectOS(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const ua = navigator.userAgent
|
||||
|
||||
if (/iPad|iPhone|iPod/.test(ua)) {
|
||||
const iOS = ua.match(/OS (\d+)[._](\d+)(?:[._](\d+))?/)
|
||||
return iOS ? `iOS ${iOS[1]}.${iOS[2]}${iOS[3] ? `.${iOS[3]}` : ''}` : 'iOS'
|
||||
}
|
||||
if (/Android/.test(ua)) {
|
||||
const android = ua.match(/Android (\d+(?:\.\d+)*)/)
|
||||
return android ? `Android ${android[1]}` : 'Android'
|
||||
}
|
||||
if (/Mac OS X|Macintosh/.test(ua)) {
|
||||
const mac = ua.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/)
|
||||
if (!mac) return 'macOS'
|
||||
return `macOS ${mac[1]}.${mac[2]}${mac[3] ? `.${mac[3]}` : ''}`
|
||||
}
|
||||
if (/Windows NT/.test(ua)) {
|
||||
const win = ua.match(/Windows NT (\d+\.\d+)/)
|
||||
const winMap: Record<string, string> = {
|
||||
'10.0': 'Windows 10/11',
|
||||
'6.3': 'Windows 8.1',
|
||||
'6.2': 'Windows 8',
|
||||
'6.1': 'Windows 7'
|
||||
}
|
||||
return win ? (winMap[win[1]] ?? `Windows NT ${win[1]}`) : 'Windows'
|
||||
}
|
||||
if (/CrOS/.test(ua)) return 'ChromeOS'
|
||||
if (/Linux/.test(ua)) return 'Linux'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend (`systemStats.system.os`) reports the Python platform identifier
|
||||
* for OSS / Desktop, which is the kernel name (`darwin`, `linux`, `win32`).
|
||||
* Promote those to the UA-detected version so the Pylon ticket shows
|
||||
* "macOS 14.5" instead of "darwin".
|
||||
*/
|
||||
export function normalizeOsName(
|
||||
rawOs: string | null | undefined
|
||||
): string | null {
|
||||
const uaOs = detectOS()
|
||||
if (!rawOs) return uaOs
|
||||
const lower = rawOs.toLowerCase().trim()
|
||||
if (lower === 'darwin' || lower === 'linux' || lower === 'win32') {
|
||||
return uaOs ?? rawOs
|
||||
}
|
||||
return rawOs
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Pylon prefill URL for a given form, omitting empty fields.
|
||||
* Users without prefill data still get a valid URL that opens the same form —
|
||||
* Pylon will collect those values from the user manually.
|
||||
* Builds the support URL with optional user information for pre-filling.
|
||||
* Users without login information will still get a valid support URL without pre-fill.
|
||||
*
|
||||
* @param form - Which Pylon form to open
|
||||
* @param prefill - Field values to pre-populate
|
||||
* @returns Complete Pylon form URL
|
||||
* @param params - User information to pre-fill in the support form
|
||||
* @returns Complete Zendesk support URL with query parameters
|
||||
*/
|
||||
export function buildSupportUrl(
|
||||
form: SupportForm = SupportForm.Question,
|
||||
prefill: SupportPrefill = {}
|
||||
): string {
|
||||
const pairs: string[] = []
|
||||
const push = (slug: string, value: string | null | undefined) => {
|
||||
const pair = encodePair(slug, value)
|
||||
if (pair) pairs.push(pair)
|
||||
export function buildSupportUrl(params?: {
|
||||
userEmail?: string | null
|
||||
userId?: string | null
|
||||
}): string {
|
||||
const searchParams = new URLSearchParams({
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
|
||||
})
|
||||
|
||||
if (params?.userEmail) {
|
||||
searchParams.append(ZENDESK_FIELDS.ANONYMOUS_EMAIL, params.userEmail)
|
||||
searchParams.append(ZENDESK_FIELDS.EMAIL, params.userEmail)
|
||||
}
|
||||
if (params?.userId) {
|
||||
searchParams.append(ZENDESK_FIELDS.USER_ID, params.userId)
|
||||
}
|
||||
|
||||
push(PYLON_FIELDS.EMAIL, prefill.userEmail)
|
||||
push(PYLON_FIELDS.COMFY_CLOUD_USER_ID, prefill.userId)
|
||||
push(PYLON_FIELDS.COMFY_ENVIRONMENT, getEnvironment())
|
||||
push(PYLON_FIELDS.COMFY_VERSION, prefill.version)
|
||||
push(PYLON_FIELDS.COMFY_OS, prefill.os)
|
||||
push(PYLON_FIELDS.BROWSER, detectBrowser())
|
||||
push(PYLON_FIELDS.PRODUCT_AREA, prefill.productArea)
|
||||
|
||||
const query = pairs.join('&')
|
||||
return `${PYLON_FORMS_BASE_URL}${form}${query ? `?${query}` : ''}`
|
||||
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
SupportForm,
|
||||
buildSupportUrl,
|
||||
normalizeOsName
|
||||
} from '@/platform/support/config'
|
||||
import type { SupportPrefill } from '@/platform/support/config'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
/**
|
||||
* Resolves Pylon prefill data from the current user session + system stats and
|
||||
* exposes a single `openSupport(form, extras?)` action that opens the best-fit
|
||||
* Pylon form in a new tab.
|
||||
*
|
||||
* Resolution is deferred until `openSupport`/`buildPrefill` is actually called
|
||||
* — call sites that never invoke them don't pay the cost of (or fail because
|
||||
* of) booting Firebase auth at component setup time.
|
||||
*/
|
||||
export function useSupportContext() {
|
||||
const buildPrefill = (extra?: Partial<SupportPrefill>): SupportPrefill => {
|
||||
const { userEmail, resolvedUserInfo } = useCurrentUser()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
return {
|
||||
userEmail: userEmail.value ?? null,
|
||||
userId: resolvedUserInfo.value?.id ?? null,
|
||||
os: normalizeOsName(systemStatsStore.systemStats?.system?.os),
|
||||
version: __COMFYUI_FRONTEND_VERSION__,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Pylon support form pre-filled with the user's context. Any field
|
||||
* we can't resolve is omitted from the URL — the form still opens.
|
||||
*
|
||||
* @param form - Which Pylon form best matches the entry-point. Defaults to
|
||||
* the generic "Question" form.
|
||||
* @param extra - Per-callsite overrides (e.g. `productArea: 'Billing'`).
|
||||
*/
|
||||
const openSupport = (
|
||||
form: SupportForm = SupportForm.Question,
|
||||
extra?: Partial<SupportPrefill>
|
||||
): void => {
|
||||
const url = buildSupportUrl(form, buildPrefill(extra))
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return {
|
||||
buildPrefill,
|
||||
openSupport
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { resolveRunErrorMessage } from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
|
||||
import { useSupportContext } from '@/platform/support/useSupportContext'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
@@ -27,11 +26,7 @@ const { copyToClipboard } = useCopyToClipboard()
|
||||
const guideUrl = buildDocsUrl('troubleshooting/overview', {
|
||||
includeLocale: true
|
||||
})
|
||||
const { buildPrefill } = useSupportContext()
|
||||
const supportUrl = buildSupportUrl(
|
||||
SupportForm.Bug,
|
||||
buildPrefill({ productArea: 'Linear Mode' })
|
||||
)
|
||||
const supportUrl = buildSupportUrl()
|
||||
|
||||
const inputNodeIds = computed(() => {
|
||||
const ids = new Set()
|
||||
|
||||
@@ -25,13 +25,15 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
return {
|
||||
flags: {
|
||||
teamWorkspacesEnabled: true
|
||||
teamWorkspacesEnabled: true,
|
||||
consolidatedBillingEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user