Compare commits

..

5 Commits

Author SHA1 Message Date
Austin Mroz
fa17e0f21c Fix tests 2026-06-24 14:44:33 -07:00
Austin Mroz
9dafefdd49 Add e2e link pan test 2026-06-24 14:44:33 -07:00
Austin Mroz
ee4cf10baa Fix space-bar pan while moving vue links 2026-06-24 14:44:33 -07:00
Connor Byrne
ad57cf2931 refactor: use canvas event for read-only sync instead of callback
Replace LGraphCanvas.onReadOnlyChanged callback property with a
'litegraph:read-only-changed' CustomEvent dispatched on the canvas
DOM element. canvasStore now subscribes via useEventListener,
matching the existing pattern used for litegraph:set-graph,
subgraph-opened, subgraph-converted, and litegraph:ghost-placement.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r2831838643
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8998#discussion_r3184409804
2026-06-24 14:44:33 -07:00
bymyself
92215f6a6e fix: space bar panning over Vue nodes in standard nav mode
Bridge LGraphCanvas.read_only to Vue reactivity via onReadOnlyChanged
callback so the existing CSS pointer-events-auto/none toggle on
LGraphNode.vue and NodeWidgets.vue re-evaluates when space key
toggles panning mode. Events then fall through to the LiteGraph
canvas naturally — no per-handler forwarding or force flags needed.

Fixes #7806

Amp-Thread-ID: https://ampcode.com/threads/T-019c796c-e83c-769d-85f4-20a349994bad
2026-06-24 14:44:31 -07:00
46 changed files with 389 additions and 634 deletions

View File

@@ -88,9 +88,9 @@ jobs:
- name: Strip non-source entries from coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
lcov --remove coverage/playwright/coverage.lcov \
'*localhost-8188*' \
'assets/images/*' \
-o coverage/playwright/coverage.lcov \
--ignore-errors unused
wc -l coverage/playwright/coverage.lcov
@@ -121,8 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--synthesize-missing
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -47,11 +47,6 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -78,7 +73,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://comfy.org/download/windows/nsis/x64"]'
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -72,7 +72,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://comfy.org/download/windows/nsis/x64',
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

@@ -1,165 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`, which in
* a personal workspace routes through the legacy `/customers/*` endpoints
* (mocked here). Drives a raw `page` (not the `comfyPage` fixture) so the
* cloud app boots against fully mocked endpoints — same pattern as
* creditsTile.spec.ts. `team_workspaces_enabled: false` keeps the topbar on
* the legacy popover variant that FE-933 repointed.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay would otherwise intercept clicks on the topbar.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace: keeps the billing facade on the legacy
// `/customers/*` path when team workspaces are enabled.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
})
)
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade still
// routes a personal workspace through `/customers/*`. With subscription
// gating on, an inactive FREE user gets the "Subscribe to run" button,
// which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
await page.getByTestId('subscribe-to-run-button').click()
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -1270,3 +1270,38 @@ test(
})
}
)
test('Spacebar pan', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
const initialOffset = await comfyPage.canvasOps.getOffset()
await test.step('Setup link drag', async () => {
await comfyPage.searchBoxV2.addNode('Load Diffusion')
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Diff')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await loadNode.getSlot('MODEL').hover()
await comfyPage.page.mouse.down()
await ksampler.getSlot('model').hover()
expect(await comfyPage.canvasOps.getOffset()).toEqual(initialOffset)
})
await test.step('Holding space initiates a pan', async () => {
await comfyPage.page.keyboard.down(' ')
await comfyPage.page.mouse.move(100, 100)
await comfyPage.page.keyboard.up(' ')
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(initialOffset)
})
await test.step('Mouse remains over model after pan', async () => {
await comfyPage.page.mouse.up()
await expect
.poll(() =>
comfyPage.page.evaluate(
() => graph?.nodes?.at(-1)?.outputs?.[0]?.links?.length === 1
)
)
.toBe(true)
})
})

View File

@@ -19,10 +19,7 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

View File

@@ -1,18 +1,37 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -21,6 +40,7 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -30,6 +50,7 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -40,50 +61,60 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -96,6 +127,7 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -105,10 +137,22 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -118,12 +162,14 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -132,37 +178,25 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: defineComponent({
default: {
name: 'SubscribeButtonMock',
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
render() {
return h('div', 'Subscribe Button')
}
})
}
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
}
mockIsLoading.value = false
mockAuthStoreState.isFetchingBalance = false
})
function renderComponent() {
@@ -196,47 +230,7 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -251,14 +245,6 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -338,11 +324,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
currency: 'usd'
}
@@ -359,10 +345,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
currency: 'usd'
}
@@ -379,10 +365,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
currency: 'usd'
}
@@ -399,9 +385,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
currency: 'usd'
}
@@ -418,8 +404,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
renderComponent()
@@ -478,11 +466,8 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,7 +32,12 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -157,15 +162,16 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -175,29 +181,25 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -209,12 +211,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const currentTier = tier.value
const tier = subscriptionTier.value
return (
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
)
})
@@ -268,6 +270,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void fetchBalance()
void authActions.fetchBalance()
})
</script>

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,37 +246,3 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,7 +128,6 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -411,8 +411,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
set read_only(value: boolean) {
const changed = this.state.readOnly !== value
this.state.readOnly = value
this._updateCursorStyle()
if (changed) {
this.dispatchEvent('litegraph:read-only-changed', { readOnly: value })
}
}
get isDragging(): boolean {
@@ -3974,7 +3978,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._previously_dragging_canvas === null) {
this._previously_dragging_canvas = this.dragging_canvas
}
this.dragging_canvas = this.pointer.isDown
this.dragging_canvas =
this.pointer.isDown || !!this.linkConnector.renderLinks.length
block_default = true
} else if (e.key === 'Escape') {
// esc

View File

@@ -59,4 +59,9 @@ export interface LGraphCanvasEventMap {
active: boolean
nodeId: NodeId
}
/** The canvas read-only state has changed. */
'litegraph:read-only-changed': {
readOnly: boolean
}
}

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"saveYearly": "وفّر 20%",
"yearlyDiscount": "خصم 20%",
"yourPlanIncludes": "خطتك تشمل:"
},
"tabMenu": {

View File

@@ -2563,7 +2563,6 @@
"billedYearly": "{total} Billed yearly",
"monthly": "Monthly",
"yearly": "Yearly",
"saveYearly": "Save 20%",
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
@@ -2574,6 +2573,7 @@
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"saveYearly": "Ahorra 20%",
"yearlyDiscount": "20% DESCUENTO",
"yourPlanIncludes": "Tu plan incluye:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"saveYearly": "٪۲۰ صرفه‌جویی",
"yearlyDiscount": "٪۲۰ تخفیف",
"yourPlanIncludes": "طرح شما شامل:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"saveYearly": "Économisez 20 %",
"yearlyDiscount": "20% DE RÉDUCTION",
"yourPlanIncludes": "Votre forfait comprend :"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"saveYearly": "20%お得",
"yearlyDiscount": "20%割引",
"yourPlanIncludes": "ご利用プランに含まれるもの:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
"yearly": "연간",
"yearlyCreditsLabel": "연간 총 크레딧",
"saveYearly": "20% 절감",
"yearlyDiscount": "20% 할인",
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuais",
"saveYearly": "Economize 20%",
"yearlyDiscount": "20% DE DESCONTO",
"yourPlanIncludes": "Seu plano inclui:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
"yearly": "Ежегодно",
"yearlyCreditsLabel": "Годовые кредиты",
"saveYearly": "Экономия 20%",
"yearlyDiscount": "СКИДКА 20%",
"yourPlanIncludes": "Ваш план включает:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
"yearly": "Yıllık",
"yearlyCreditsLabel": "Toplam yıllık krediler",
"saveYearly": "%20 tasarruf",
"yearlyDiscount": "%20 İNDİRİM",
"yourPlanIncludes": "Planınız şunları içerir:"
},
"tabMenu": {

View File

@@ -3847,7 +3847,7 @@
"workspaceNotSubscribed": "此工作區尚未訂閱",
"yearly": "每年",
"yearlyCreditsLabel": "年度總點數",
"saveYearly": "節省 20%",
"yearlyDiscount": "八折優惠",
"yourPlanIncludes": "您的方案包含:"
},
"tabMenu": {

View File

@@ -3859,7 +3859,7 @@
"workspaceNotSubscribed": "此工作区未订阅",
"yearly": "年度",
"yearlyCreditsLabel": "总共年度积分",
"saveYearly": "立省 20%",
"yearlyDiscount": "20% 减免",
"yourPlanIncludes": "您的计划包括:"
},
"tabMenu": {

View File

@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
renewalDate: mockRenewalDate
}))
}))
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(FreeTierDialogContent, {
global: {
plugins: [i18n]
}
})
}
describe('FreeTierDialogContent', () => {
it('renders the next refresh line formatted from the facade renewalDate', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent()
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('hides the next refresh line when renewalDate is null', () => {
mockRenewalDate.value = null
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -102,9 +102,9 @@
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
@@ -116,17 +116,7 @@ defineEmits<{
upgrade: []
}>()
const { renewalDate } = useBillingContext()
const formattedRenewalDate = computed(() => {
if (!renewalDate.value) return ''
return new Date(renewalDate.value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
const { formattedRenewalDate } = useSubscription()
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -7,7 +7,6 @@ import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
async function flushPromises() {
@@ -24,8 +23,10 @@ function createDeferredPromise<T>() {
}
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
@@ -64,25 +65,13 @@ Object.defineProperty(globalThis, 'localStorage', {
writable: true
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
}
: null
)
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)
})
}))
@@ -228,7 +217,7 @@ describe('PricingTable', () => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockSubscriptionDuration.value = 'MONTHLY'
mockIsYearlySubscription.value = false
mockUserId.value = 'user-123'
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
@@ -373,7 +362,6 @@ describe('PricingTable', () => {
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
renderComponent()
await flushPromises()
@@ -382,29 +370,12 @@ describe('PricingTable', () => {
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeDefined()
expect(currentPlanButton).toBeDisabled()
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
expect(currentPlanButton).toBeUndefined()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false

View File

@@ -30,9 +30,9 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
{{ t('subscription.saveYearly') }}
-20%
</div>
</div>
</template>
@@ -67,15 +67,15 @@
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
>
${{ getPrice(tier) }}
<span
v-show="currentBillingCycle === 'yearly'"
class="text-2xl text-muted-foreground line-through"
>
${{ tier.pricing.monthly }}
</span>
${{ getPrice(tier) }}
</span>
<span class="font-inter text-xl/normal text-base-foreground">
{{ t('subscription.usdPerMonth') }}
@@ -122,12 +122,9 @@
}}
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
aria-hidden="true"
/>
<i class="icon-[lucide--component] text-sm text-amber-400" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
{{ n(getCreditsDisplay(tier)) }}
</span>
@@ -139,7 +136,7 @@
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
{{ tier.maxDuration }}
</span>
@@ -189,7 +186,7 @@
</div>
</div>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
class="font-inter text-sm/normal font-bold text-base-foreground"
>
~{{ n(tier.pricing.videoEstimate) }}
</span>
@@ -266,8 +263,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
TIER_PRICING,
TIER_TO_KEY
@@ -364,13 +361,9 @@ const tiers: PricingTierConfig[] = [
const {
isActiveSubscription,
isFreeTier,
tier: subscriptionTier,
subscription
} = useBillingContext()
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
subscriptionTier,
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()

View File

@@ -16,6 +16,7 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@comfyorg/tailwind-utils'
@@ -37,8 +38,8 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog, tier } =
useBillingContext()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -53,7 +54,7 @@ watch(
const handleSubscribe = () => {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: tier.value?.toLowerCase()
current_tier: subscriptionTier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()

View File

@@ -2,26 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mockToastAdd })
}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockAuthFetchBalance
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
fetchBalance: mockBillingFetchBalance,
fetchStatus: mockFetchStatus
})
}))
@@ -119,21 +119,20 @@ describe('useSubscriptionActions', () => {
})
describe('handleRefresh', () => {
it('should refresh balance and status through the billing facade', async () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
})
it('swallows refresh failures without surfacing a toast', async () => {
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,7 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -10,9 +11,10 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchBalance, fetchStatus } = useBillingContext()
const { fetchStatus } = useBillingContext()
const isLoadingSupport = ref(false)
@@ -42,7 +44,7 @@ export function useSubscriptionActions() {
const handleRefresh = async () => {
try {
await Promise.all([fetchBalance(), fetchStatus()])
await Promise.all([authActions.fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}

View File

@@ -129,21 +129,6 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true

View File

@@ -129,15 +129,18 @@ export const useSubscriptionDialog = () => {
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
},
dialogComponentProps: {
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is ignored here and collapses the table to the default
// `md` frame. `w-fit` lets each step hug its content — the pricing
// table fills its 1280px content while the compact confirm/success
// steps shrink (the content root sets its own width per checkoutStep).
renderer: 'reka',
size: 'full',
contentClass:
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
// The dialog hugs its content so each step sizes itself: the pricing
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
// compact confirm/success steps shrink instead of floating in the big
// pricing modal. Sizes are set on the content root per checkoutStep.
style: 'max-width: 95vw; max-height: 90vh;',
pt: {
root: { class: 'rounded-2xl bg-transparent' },
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -130,7 +130,7 @@ const {
getSearchResults
} = useSettingSearch()
const { fetchBalance } = useBillingContext()
const authActions = useAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
@@ -238,7 +238,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void fetchBalance()
void authActions.fetchBalance()
}
if (newKey) {
void nextTick(() => {

View File

@@ -1,7 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { TelemetryEvents } from '../../types'
@@ -15,10 +12,6 @@ const hoisted = vi.hoisted(() => {
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
const refs = {
tier: null as unknown as Ref<string | null>,
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>
}
return {
mockCapture,
@@ -30,7 +23,6 @@ const hoisted = vi.hoisted(() => {
mockReset,
mockOnUserResolved,
mockOnUserLogout,
refs,
mockPosthog: {
default: {
init: mockInit,
@@ -44,6 +36,14 @@ const hoisted = vi.hoisted(() => {
}
})
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved,
@@ -51,19 +51,21 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
vi.mock('@/platform/remoteConfig/remoteConfig', async () => {
const { ref } = await vi.importActual<typeof VueModule>('vue')
hoisted.refs.remoteConfig = ref<Record<string, unknown> | null>(null)
return { remoteConfig: hoisted.refs.remoteConfig }
})
const mockRemoteConfig = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
vi.mock('posthog-js', () => hoisted.mockPosthog)
vi.mock('@/composables/billing/useBillingContext', async () => {
const { ref } = await vi.importActual<typeof VueModule>('vue')
hoisted.refs.tier = ref<string | null>(null)
return { useBillingContext: () => ({ tier: hoisted.refs.tier }) }
})
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
subscriptionTier: { value: null }
})
}))
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
@@ -80,10 +82,7 @@ function createProvider(
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.refs.remoteConfig.value = null
// Fresh tier ref per test: each provider registers an undisposed tier
// watch, so a shared ref would leak watchers across tests.
hoisted.refs.tier = ref<string | null>(null)
mockRemoteConfig.value = null
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
@@ -117,7 +116,7 @@ describe('PostHogTelemetryProvider', () => {
})
it('applies posthog_config overrides from remote config', async () => {
hoisted.refs.remoteConfig.value = {
mockRemoteConfig.value = {
posthog_config: {
debug: true,
api_host: 'https://custom.host.com'
@@ -151,48 +150,6 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
})
function tierPropertySets(): unknown[] {
return hoisted.mockPeopleSet.mock.calls
.map(([props]) => props)
.filter((props) => props && 'subscription_tier' in props)
}
it('sets subscription_tier reactively when the facade tier resolves', async () => {
createProvider()
await vi.dynamicImportSettled()
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
onResolved({ id: 'user-123' })
// Unresolved tier (null) does not set the property
expect(tierPropertySets()).toHaveLength(0)
hoisted.refs.tier.value = 'PRO'
await nextTick()
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
subscription_tier: 'PRO'
})
hoisted.refs.tier.value = null
await nextTick()
expect(tierPropertySets()).toHaveLength(1)
})
it('keeps a single tier watcher across repeated user resolutions', async () => {
createProvider()
await vi.dynamicImportSettled()
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
onResolved({ id: 'user-1' })
onResolved({ id: 'user-1' })
onResolved({ id: 'user-2' })
hoisted.refs.tier.value = 'PRO'
await nextTick()
expect(tierPropertySets()).toHaveLength(1)
})
})
describe('desktop entry capture', () => {
@@ -713,7 +670,7 @@ describe('PostHogTelemetryProvider', () => {
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
const remoteBefore_send = vi.fn()
hoisted.refs.remoteConfig.value = {
mockRemoteConfig.value = {
posthog_config: {
before_send: remoteBefore_send,
person_profiles: 'always'

View File

@@ -1,11 +1,10 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -99,7 +98,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private isInitialized = false
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
private stopSubscriptionTierWatch: WatchStopHandle | null = null
constructor() {
this.configureDisabledEvents(
@@ -309,13 +307,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
private setSubscriptionProperties(): void {
if (this.stopSubscriptionTierWatch) return
const { tier } = useBillingContext()
this.stopSubscriptionTierWatch = watch(
tier,
(value) => {
if (value && this.posthog) {
this.posthog.people.set({ subscription_tier: value })
const { subscriptionTier } = useSubscription()
watch(
subscriptionTier,
(tier) => {
if (tier && this.posthog) {
this.posthog.people.set({ subscription_tier: tier })
}
},
{ immediate: true }

View File

@@ -2,10 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
vi.mock('@/composables/useAppMode', () => ({
@@ -39,6 +41,30 @@ vi.mock('@/scripts/app', () => ({
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useEventListener: vi.fn(
(
target: EventTarget,
event: string,
handler: EventListenerOrEventListenerObject
) => {
target.addEventListener(event, handler)
return () => target.removeEventListener(event, handler)
}
)
}
})
function createMockCanvas(readOnly = false): LGraphCanvas {
return {
read_only: readOnly,
canvas: document.createElement('canvas')
} as unknown as LGraphCanvas
}
describe('useCanvasStore', () => {
let store: ReturnType<typeof useCanvasStore>
@@ -129,4 +155,42 @@ describe('useCanvasStore', () => {
expect(store.selectedNodeIds).toHaveLength(0)
})
describe('isReadOnly', () => {
it('syncs initial read_only value when canvas is set', async () => {
const mockCanvas = createMockCanvas(true)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(true)
})
it('updates isReadOnly when litegraph:read-only-changed event fires', async () => {
const mockCanvas = createMockCanvas(false)
store.canvas = mockCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.isReadOnly).toBe(false)
// Simulate space key press → LGraphCanvas sets read_only = true
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: true }
})
)
expect(store.isReadOnly).toBe(true)
// Simulate space key release
mockCanvas.canvas.dispatchEvent(
new CustomEvent('litegraph:read-only-changed', {
detail: { readOnly: false }
})
)
expect(store.isReadOnly).toBe(false)
})
})
})

View File

@@ -56,6 +56,7 @@ export const useCanvasStore = defineStore('canvas', () => {
setMode(val ? 'app' : 'graph')
}
})
const isReadOnly = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
@@ -143,6 +144,16 @@ export const useCanvasStore = defineStore('canvas', () => {
}
)
isReadOnly.value = newCanvas.read_only
useEventListener(
newCanvas.canvas,
'litegraph:read-only-changed',
(event: CustomEvent<{ readOnly: boolean }>) => {
isReadOnly.value = event.detail.readOnly
}
)
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',
@@ -188,6 +199,7 @@ export const useCanvasStore = defineStore('canvas', () => {
rerouteSelected,
appScalePercentage,
linearMode,
isReadOnly,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -13,7 +13,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => {
return {
useCanvasStore: vi.fn(() => ({
getCanvas,
setCursorStyle
setCursorStyle,
isReadOnly: false
}))
}
})

View File

@@ -27,9 +27,7 @@ export function useCanvasInteractions() {
* Whether Vue node components should handle pointer events.
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
*/
const shouldHandleNodePointerEvents = computed(
() => !(canvasStore.canvas?.read_only ?? false)
)
const shouldHandleNodePointerEvents = computed(() => !canvasStore.isReadOnly)
/**
* Returns true if the wheel event target is inside an element that should

View File

@@ -52,6 +52,10 @@ vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ isReadOnly: false })
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {

View File

@@ -23,6 +23,7 @@ import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
@@ -122,6 +123,7 @@ export function useSlotLinkInteraction({
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const canvasStore = useCanvasStore()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
@@ -414,9 +416,11 @@ export function useSlotLinkInteraction({
const canvas = app.canvas
const node = canvas.graph?.getNodeById(nodeId)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
if (!pointerSession.matches(event) || canvasStore.isReadOnly) return
event.stopPropagation()
app.canvas.last_mouse = [event.clientX, event.clientY]
autoPan?.updatePointer(event.clientX, event.clientY)
if (canvas.subgraph && node) {

View File

@@ -191,14 +191,13 @@ export const useAppModeStore = defineStore('appMode', () => {
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
const canvas = getCanvas()
canvas.read_only = inSelect
unwatchReadOnly?.()
if (inSelect)
unwatchReadOnly = watch(
() => state.readOnly,
() => (state.readOnly = true)
() => canvas.read_only,
() => (canvas.read_only = true)
)
}