mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 09:09:14 +00:00
Compare commits
1 Commits
austin/ima
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842e3d7541 |
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -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",
|
||||
|
||||
@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -213,7 +214,8 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -47,6 +49,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -13,11 +15,14 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -28,6 +33,15 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
}
|
||||
type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -210,7 +224,321 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const ROW_MARKER = '__row__'
|
||||
const rowHeaderName = (group: string, row: number) =>
|
||||
`${group}.${ROW_MARKER}${row}`
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Extract the row index from a header widget name, or `undefined`. */
|
||||
function headerRowIndex(group: string, name: string): number | undefined {
|
||||
const prefix = `${group}.${ROW_MARKER}`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const row = Number(name.slice(prefix.length))
|
||||
return Number.isInteger(row) ? row : undefined
|
||||
}
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
|
||||
|
||||
const CANVAS_MARGIN = 15
|
||||
|
||||
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
|
||||
function drawGroupButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
disabled: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
if (disabled) ctx.globalAlpha *= 0.5
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
|
||||
height * 0.5
|
||||
])
|
||||
ctx.fill()
|
||||
if (!disabled) ctx.stroke()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, width * 0.5, y + height * 0.7)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Horizontal centre of a row header's remove (✕) hit target. */
|
||||
const removeButtonCenterX = (width: number) =>
|
||||
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
|
||||
function drawGroupRowHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
removable: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
|
||||
if (removable) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const countGroupRows = (group: string, node: LGraphNode): number =>
|
||||
(node.widgets ?? []).reduce(
|
||||
(count, w) =>
|
||||
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
|
||||
0
|
||||
)
|
||||
|
||||
/** Build a row's header + field widgets, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
const header = node.addCustomWidget({
|
||||
name: rowHeaderName(group, row),
|
||||
type: 'dynamic_group_row',
|
||||
value: row,
|
||||
y: 0,
|
||||
serialize: false,
|
||||
callback: undefined as IBaseWidget['callback'],
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
const idx = headerRowIndex(group, this.name) ?? 0
|
||||
const label = t('dynamicGroup.row', { index: idx + 1 })
|
||||
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
|
||||
if (event.type !== 'pointerup' || !this.options?.removable) return false
|
||||
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
|
||||
return false
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, removable: row >= state.min }
|
||||
})
|
||||
header.callback = function (this: IBaseWidget) {
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
}
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || w.name.startsWith(`${group}.`)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const headerRow = headerRowIndex(group, w.name)
|
||||
if (headerRow !== undefined && headerRow > row) {
|
||||
w.name = rowHeaderName(group, headerRow - 1)
|
||||
w.options ??= {}
|
||||
w.options.removable = headerRow - 1 >= state.min
|
||||
continue
|
||||
}
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => name.startsWith(`${group}.`)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => isRowMember(inp.name))
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group_add',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
drawGroupButton(
|
||||
ctx,
|
||||
width,
|
||||
y,
|
||||
t('dynamicGroup.addRow'),
|
||||
!!this.options?.disabled
|
||||
)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
|
||||
if (event.type !== 'pointerup' || this.options?.disabled) return false
|
||||
addRow(inputName, typedNode)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, disabled: false }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
rebuildRows(inputName, count, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -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', () => ({}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
|
||||
"yearly": "سنوي",
|
||||
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
|
||||
"saveYearly": "وفّر 20%",
|
||||
"yearlyDiscount": "خصم 20%",
|
||||
"yourPlanIncludes": "خطتك تشمل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -2233,6 +2233,11 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"row": "Row {index}"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
@@ -2563,7 +2568,6 @@
|
||||
"billedYearly": "{total} Billed yearly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"saveYearly": "Save 20%",
|
||||
"tierNameYearly": "{name} Yearly",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
@@ -2574,6 +2578,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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3859,7 +3859,7 @@
|
||||
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
|
||||
"yearly": "سالانه",
|
||||
"yearlyCreditsLabel": "کل اعتبار سالانه",
|
||||
"saveYearly": "٪۲۰ صرفهجویی",
|
||||
"yearlyDiscount": "٪۲۰ تخفیف",
|
||||
"yourPlanIncludes": "طرح شما شامل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Cet espace de travail n’a pas d’abonnement",
|
||||
"yearly": "Annuel",
|
||||
"yearlyCreditsLabel": "Crédits annuels totaux",
|
||||
"saveYearly": "Économisez 20 %",
|
||||
"yearlyDiscount": "20% DE RÉDUCTION",
|
||||
"yourPlanIncludes": "Votre forfait comprend :"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
|
||||
"yearly": "年額",
|
||||
"yearlyCreditsLabel": "年間合計クレジット",
|
||||
"saveYearly": "20%お得",
|
||||
"yearlyDiscount": "20%割引",
|
||||
"yourPlanIncludes": "ご利用プランに含まれるもの:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
|
||||
"yearly": "연간",
|
||||
"yearlyCreditsLabel": "연간 총 크레딧",
|
||||
"saveYearly": "20% 절감",
|
||||
"yearlyDiscount": "20% 할인",
|
||||
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
|
||||
"yearly": "Ежегодно",
|
||||
"yearlyCreditsLabel": "Годовые кредиты",
|
||||
"saveYearly": "Экономия 20%",
|
||||
"yearlyDiscount": "СКИДКА 20%",
|
||||
"yourPlanIncludes": "Ваш план включает:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "此工作區尚未訂閱",
|
||||
"yearly": "每年",
|
||||
"yearlyCreditsLabel": "年度總點數",
|
||||
"saveYearly": "節省 20%",
|
||||
"yearlyDiscount": "八折優惠",
|
||||
"yourPlanIncludes": "您的方案包含:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3859,7 +3859,7 @@
|
||||
"workspaceNotSubscribed": "此工作区未订阅",
|
||||
"yearly": "年度",
|
||||
"yearlyCreditsLabel": "总共年度积分",
|
||||
"saveYearly": "立省 20%",
|
||||
"yearlyDiscount": "20% 减免",
|
||||
"yourPlanIncludes": "您的计划包括:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="col-span-2 flex justify-start">
|
||||
<Button
|
||||
class="border-0 bg-component-node-widget-background px-2 py-1 text-base-foreground"
|
||||
:disabled="widget.options?.disabled"
|
||||
size="sm"
|
||||
variant="textonly"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span
|
||||
class="mr-1 icon-[material-symbols--add] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('dynamicGroup.addRow') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleClick() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-node-slot-background col-span-2 flex items-center justify-between border-t pt-1"
|
||||
>
|
||||
<span class="text-xs font-medium text-base-foreground/70">
|
||||
{{ rowLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="widget.options?.removable"
|
||||
class="hover:text-danger rounded-sm p-0.5 text-base-foreground/50 transition-colors"
|
||||
:aria-label="t('dynamicGroup.removeRow')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<span
|
||||
class="icon-[material-symbols--close] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rowLabel = computed(() => {
|
||||
const match = /__row__(\d+)$/.exec(widget.name)
|
||||
const index = match ? Number(match[1]) : 0
|
||||
return t('dynamicGroup.row', { index: index + 1 })
|
||||
})
|
||||
|
||||
function handleRemove() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -53,20 +53,10 @@
|
||||
<!-- Circular drag handle -->
|
||||
<div
|
||||
v-if="hasCompareImages"
|
||||
class="pointer-events-none absolute top-0 z-10 h-full w-6"
|
||||
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 h-[calc(50%-var(--spacing)*3)] w-0.25 bg-white/30 backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-1/2 size-6 -translate-1/2 rounded-full border-2 bg-white/30 shadow-lg backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-0 h-[calc(50%-var(--spacing)*3)] w-0.25 bg-white/30 backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -75,6 +75,14 @@ const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
const WidgetColors = defineAsyncComponent(
|
||||
() => import('@/components/palette/WidgetColors.vue')
|
||||
)
|
||||
const WidgetDynamicGroupAdd = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupAdd.vue')
|
||||
)
|
||||
const WidgetDynamicGroupRow = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupRow.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -241,6 +249,22 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_add',
|
||||
{
|
||||
component: WidgetDynamicGroupAdd,
|
||||
aliases: ['COMFY_DYNAMICGROUP_V3'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_row',
|
||||
{
|
||||
component: WidgetDynamicGroupRow,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user