mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-20 23:13:40 +00:00
Compare commits
2 Commits
test/cov-S
...
glary/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52fa622ea | ||
|
|
a065087e2f |
63
browser_tests/assets/nodes/note_with_ksampler.json
Normal file
63
browser_tests/assets/nodes/note_with_ksampler.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [50, 50],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["This is a reference note"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [50, 250],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["# Markdown heading"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
82
browser_tests/tests/noteNodeApiExport.spec.ts
Normal file
82
browser_tests/tests/noteNodeApiExport.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Note Node API Export', { tag: '@node' }, () => {
|
||||
test('excludes Note and MarkdownNote from API format export', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
|
||||
expect(classTypes, 'API output should not contain Note').not.toContain(
|
||||
'Note'
|
||||
)
|
||||
expect(
|
||||
classTypes,
|
||||
'API output should not contain MarkdownNote'
|
||||
).not.toContain('MarkdownNote')
|
||||
expect(
|
||||
Object.keys(apiWorkflow),
|
||||
'All-virtual workflow should produce empty API output'
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('preserves real nodes while filtering virtual ones', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const entries = Object.values(apiWorkflow)
|
||||
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
|
||||
expect(entries[0].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('standard workflow export still includes Note nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
const noteNodes = workflow.nodes.filter(
|
||||
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
|
||||
)
|
||||
expect(
|
||||
noteNodes,
|
||||
'Standard export must preserve both Note and MarkdownNote'
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('no virtual node types leak through graphToPrompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
|
||||
const { output } = await window.app!.graphToPrompt()
|
||||
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
|
||||
const leaked: string[] = []
|
||||
for (const node of Object.values(output)) {
|
||||
if (virtualTypes.includes(node.class_type)) {
|
||||
leaked.push(node.class_type)
|
||||
}
|
||||
}
|
||||
return { leaked, totalNodes: Object.keys(output).length }
|
||||
})
|
||||
|
||||
expect(
|
||||
virtualNodeCheck.leaked,
|
||||
'No virtual node types should leak into API output'
|
||||
).toHaveLength(0)
|
||||
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -2369,7 +2369,6 @@
|
||||
"resubscribe": "Resubscribe",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"resubscribeFailed": "Failed to resubscribe",
|
||||
"canceledCard": {
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
|
||||
91
src/platform/cloud/onboarding/auth.test.ts
Normal file
91
src/platform/cloud/onboarding/auth.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { getSurveyCompletedStatus } from './auth'
|
||||
|
||||
const fetchApi = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => fetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@sentry/vue', () => ({
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn()
|
||||
}))
|
||||
|
||||
function mockResponse({
|
||||
ok,
|
||||
status,
|
||||
body
|
||||
}: {
|
||||
ok: boolean
|
||||
status: number
|
||||
body?: unknown
|
||||
}): Response {
|
||||
return fromPartial<Response>({
|
||||
ok,
|
||||
status,
|
||||
statusText: '',
|
||||
json: async () => body
|
||||
})
|
||||
}
|
||||
|
||||
describe('getSurveyCompletedStatus', () => {
|
||||
beforeEach(() => {
|
||||
fetchApi.mockReset()
|
||||
})
|
||||
|
||||
test('200 with non-empty value → true', async () => {
|
||||
fetchApi.mockResolvedValueOnce(
|
||||
mockResponse({ ok: true, status: 200, body: { value: { q1: 'a' } } })
|
||||
)
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('200 with empty value → false (the only "not completed" signal)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(
|
||||
mockResponse({ ok: true, status: 200, body: { value: {} } })
|
||||
)
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
test('200 with null value → false', async () => {
|
||||
fetchApi.mockResolvedValueOnce(
|
||||
mockResponse({ ok: true, status: 200, body: { value: null } })
|
||||
)
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
test('404 → true (do not bounce on missing key)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 404 }))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('500 → true (do not bounce on transient backend error)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
// 401/403 fall under the same "ambiguous => treat as completed" policy.
|
||||
// The dedicated auth layer handles re-authentication on the next API
|
||||
// call; this function deliberately does not try to disambiguate auth
|
||||
// failures from other non-OK responses. Locking with tests so the
|
||||
// policy can't drift back to a "throw on auth error" branch.
|
||||
test('401 → true (auth layer handles re-auth on next call)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 401 }))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('403 → true (auth layer handles re-auth on next call)', async () => {
|
||||
fetchApi.mockResolvedValueOnce(mockResponse({ ok: false, status: 403 }))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('network rejection → true (do not bounce on network error)', async () => {
|
||||
fetchApi.mockRejectedValueOnce(new TypeError('Network request failed'))
|
||||
await expect(getSurveyCompletedStatus()).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -96,23 +96,24 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Not an error case - survey not completed is a valid state
|
||||
// Ambiguous response (404/5xx/etc). Treat as completed to avoid
|
||||
// bouncing working customers to /cloud/survey on transient hiccups.
|
||||
// Real "not completed" only comes from a 200 with empty value.
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'auth',
|
||||
message: 'Survey status check returned non-ok response',
|
||||
level: 'info',
|
||||
level: 'warning',
|
||||
data: {
|
||||
status: response.status,
|
||||
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
})
|
||||
return false
|
||||
return true
|
||||
}
|
||||
const data = await response.json()
|
||||
// Check if data exists and is not empty
|
||||
return !isEmpty(data.value)
|
||||
} catch (error) {
|
||||
// Network error - still capture it as it's not thrown from above
|
||||
// Network/parse failure — same policy as ambiguous HTTP responses.
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_endpoint: '/settings/{key}',
|
||||
@@ -124,7 +125,7 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
},
|
||||
level: 'warning'
|
||||
})
|
||||
return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubscriptionPanelContentWorkspace from './SubscriptionPanelContentWorkspace.vue'
|
||||
|
||||
const fns = vi.hoisted(() => ({
|
||||
manageSubscription: vi.fn(),
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
handleRefresh: vi.fn().mockResolvedValue(undefined),
|
||||
resubscribe: vi.fn().mockResolvedValue({}),
|
||||
toastAdd: vi.fn()
|
||||
}))
|
||||
|
||||
const isSettingUp = ref(false)
|
||||
const isActiveSubscription = ref(false)
|
||||
const isFreeTier = ref(false)
|
||||
const subscription = ref<Record<string, unknown> | null>(null)
|
||||
const isWorkspaceSubscribed = ref(true)
|
||||
const isInPersonalWorkspace = ref(false)
|
||||
const members = ref([{ id: '1' }, { id: '2' }])
|
||||
const perms = ref({ canManageSubscription: true, canTopUp: true })
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: fns.toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
get isSettingUp() {
|
||||
return isSettingUp.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
get isActiveSubscription() {
|
||||
return isActiveSubscription
|
||||
},
|
||||
get isFreeTier() {
|
||||
return isFreeTier
|
||||
},
|
||||
get subscription() {
|
||||
return subscription
|
||||
},
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
manageSubscription: fns.manageSubscription,
|
||||
fetchStatus: fns.fetchStatus,
|
||||
fetchBalance: fns.fetchBalance,
|
||||
getMaxSeats: () => 5
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
|
||||
() => ({
|
||||
useSubscriptionCredits: () => ({
|
||||
totalCredits: ref('100'),
|
||||
monthlyBonusCredits: ref('50'),
|
||||
prepaidCredits: ref('50'),
|
||||
isLoadingBalance: ref(false)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionActions',
|
||||
() => ({
|
||||
useSubscriptionActions: () => ({
|
||||
handleAddApiCredits: vi.fn(),
|
||||
handleRefresh: fns.handleRefresh
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
showPricingTable: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
get permissions() {
|
||||
return perms
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isWorkspaceSubscribed() {
|
||||
return isWorkspaceSubscribed
|
||||
},
|
||||
get isInPersonalWorkspace() {
|
||||
return isInPersonalWorkspace
|
||||
},
|
||||
get members() {
|
||||
return members
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: fns.resubscribe }
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showCancelSubscriptionDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
template:
|
||||
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['disabled', 'loading', 'variant', 'size']
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(SubscriptionPanelContentWorkspace, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
StatusBadge: {
|
||||
name: 'StatusBadge',
|
||||
template: '<span data-testid="status-badge"><slot /></span>',
|
||||
props: ['label', 'severity']
|
||||
},
|
||||
Skeleton: {
|
||||
name: 'Skeleton',
|
||||
template: '<div data-testid="skeleton" />',
|
||||
props: ['width', 'height']
|
||||
},
|
||||
Menu: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setSubscribedState(overrides: Record<string, unknown> = {}) {
|
||||
isActiveSubscription.value = true
|
||||
isFreeTier.value = false
|
||||
subscription.value = {
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-06-01T00:00:00Z',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('SubscriptionPanelContentWorkspace (component smoke tests)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isSettingUp.value = false
|
||||
isActiveSubscription.value = false
|
||||
isFreeTier.value = false
|
||||
subscription.value = null
|
||||
isWorkspaceSubscribed.value = true
|
||||
isInPersonalWorkspace.value = false
|
||||
members.value = [{ id: '1' }, { id: '2' }]
|
||||
perms.value = { canManageSubscription: true, canTopUp: true }
|
||||
})
|
||||
|
||||
it('shows loading spinner when setting up', () => {
|
||||
isSettingUp.value = true
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('billingOperation.subscriptionProcessing')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders subscribed state with tier name and price', () => {
|
||||
setSubscribedState()
|
||||
renderComponent()
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toContain('$20')
|
||||
})
|
||||
|
||||
it('calls handleRefresh on refresh button click', async () => {
|
||||
setSubscribedState()
|
||||
renderComponent()
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const refreshButton = allButtons.find(
|
||||
(b) => b.textContent === '' && !b.getAttribute('aria-label')
|
||||
)
|
||||
expect(refreshButton).toBeTruthy()
|
||||
await userEvent.click(refreshButton!)
|
||||
expect(fns.handleRefresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls workspaceApi.resubscribe on resubscribe click', async () => {
|
||||
setSubscribedState({ isCancelled: true, endDate: '2026-07-15T00:00:00Z' })
|
||||
renderComponent()
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /subscription.resubscribe/
|
||||
})
|
||||
await userEvent.click(btn)
|
||||
expect(fns.resubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows view more details link for owners', () => {
|
||||
setSubscribedState()
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByRole('link', { name: /subscription.viewMoreDetailsPlans/ })
|
||||
).toHaveAttribute('href', 'https://www.comfy.org/cloud/pricing')
|
||||
})
|
||||
})
|
||||
@@ -368,27 +368,24 @@ import { useToast } from 'primevue/usetoast'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { getTierPrice } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
formatRefillsDate,
|
||||
formatSubscriptionDate,
|
||||
getNextMonthInvoice,
|
||||
getPlanTotalCreditsValue,
|
||||
getSubscriptionTierKey
|
||||
} from './subscriptionPanelWorkspace.logic'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
|
||||
storeToRefs(workspaceStore)
|
||||
@@ -413,16 +410,40 @@ const {
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
const isResubscribing = ref(false)
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Only show cancelled state for team workspaces (workspace billing)
|
||||
// Personal workspaces use legacy billing which has different cancellation semantics
|
||||
const isCancelled = computed(
|
||||
() =>
|
||||
!isInPersonalWorkspace.value && (subscription.value?.isCancelled ?? false)
|
||||
)
|
||||
|
||||
// Show subscribe prompt to owners without active subscription
|
||||
// Don't show if subscription is cancelled (still active until end date)
|
||||
const showSubscribePrompt = computed(() => {
|
||||
if (!permissions.value.canManageSubscription) return false
|
||||
if (isCancelled.value) return false
|
||||
@@ -430,6 +451,7 @@ const showSubscribePrompt = computed(() => {
|
||||
return !isWorkspaceSubscribed.value
|
||||
})
|
||||
|
||||
// MEMBER view without subscription - members can't manage subscription
|
||||
const isMemberView = computed(
|
||||
() =>
|
||||
!permissions.value.canManageSubscription &&
|
||||
@@ -437,10 +459,12 @@ const isMemberView = computed(
|
||||
!isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
// Show zero state for credits (no real billing data yet)
|
||||
const showZeroState = computed(
|
||||
() => showSubscribePrompt.value || isMemberView.value
|
||||
)
|
||||
|
||||
// Subscribe workspace - opens the subscription dialog (personal or workspace variant)
|
||||
function handleSubscribeWorkspace() {
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
@@ -453,31 +477,36 @@ function handleUpgrade() {
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
const subscriptionTier = computed(() => subscription.value?.tier ?? null)
|
||||
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
|
||||
const tierKey = computed(() => getSubscriptionTierKey(subscriptionTier.value))
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const renewalDate = new Date(subscription.value.renewalDate)
|
||||
return renewalDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const formattedRenewalDate = computed(() =>
|
||||
formatSubscriptionDate(subscription.value?.renewalDate)
|
||||
)
|
||||
|
||||
const formattedEndDate = computed(() =>
|
||||
formatSubscriptionDate(subscription.value?.endDate)
|
||||
)
|
||||
const formattedEndDate = computed(() => {
|
||||
if (!subscription.value?.endDate) return ''
|
||||
const endDate = new Date(subscription.value.endDate)
|
||||
return endDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const subscriptionTierName = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return ''
|
||||
const baseName = t(`subscription.tiers.${tierKey.value}.name`)
|
||||
const key = TIER_TO_KEY[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearlySubscription.value
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
@@ -495,36 +524,54 @@ const planMenuItems = computed(() => [
|
||||
}
|
||||
])
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const memberCount = computed(() => members.value.length)
|
||||
const nextMonthInvoice = computed(() => memberCount.value * tierPrice.value)
|
||||
|
||||
const nextMonthInvoice = computed(() =>
|
||||
getNextMonthInvoice(memberCount.value, tierPrice.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() =>
|
||||
formatRefillsDate(subscription.value?.renewalDate)
|
||||
)
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscription.value?.renewalDate) return ''
|
||||
const date = new Date(subscription.value.renewalDate)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{ date: refillsDate.value },
|
||||
{ escapeParameter: false }
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{ date: refillsDate.value },
|
||||
{ escapeParameter: false }
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const total = getPlanTotalCreditsValue(
|
||||
tierKey.value,
|
||||
isYearlySubscription.value
|
||||
)
|
||||
return total === null ? '—' : n(total)
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return '—'
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
@@ -532,52 +579,30 @@ const includedCreditsDisplay = computed(
|
||||
)
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] => {
|
||||
const key = tierKey.value
|
||||
const benefits: TierBenefit[] = []
|
||||
|
||||
if (!isInPersonalWorkspace.value) {
|
||||
benefits.push({
|
||||
key: 'members',
|
||||
type: 'icon',
|
||||
label: t('subscription.membersLabel', {
|
||||
count: getMaxSeats(tierKey.value)
|
||||
}),
|
||||
label: t('subscription.membersLabel', { count: getMaxSeats(key) }),
|
||||
icon: 'pi pi-user'
|
||||
})
|
||||
}
|
||||
|
||||
benefits.push(...getCommonTierBenefits(tierKey.value, t, n))
|
||||
benefits.push(...getCommonTierBenefits(key, t, n))
|
||||
return benefits
|
||||
})
|
||||
|
||||
const isResubscribing = ref(false)
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('subscription.resubscribeFailed')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
@@ -585,11 +610,13 @@ function handleWindowFocus() {
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
formatRefillsDate,
|
||||
formatSubscriptionDate,
|
||||
getNextMonthInvoice,
|
||||
getPlanTotalCreditsValue,
|
||||
getSubscriptionTierKey
|
||||
} from './subscriptionPanelWorkspace.logic'
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: { free_tier_credits: 100 } }
|
||||
}))
|
||||
|
||||
describe('getSubscriptionTierKey', () => {
|
||||
it('returns default key for null tier', () => {
|
||||
expect(getSubscriptionTierKey(null)).toBe('standard')
|
||||
})
|
||||
|
||||
it('returns default key for undefined tier', () => {
|
||||
expect(getSubscriptionTierKey(undefined)).toBe('standard')
|
||||
})
|
||||
|
||||
it('maps known tiers correctly', () => {
|
||||
expect(getSubscriptionTierKey('STANDARD')).toBe('standard')
|
||||
expect(getSubscriptionTierKey('CREATOR')).toBe('creator')
|
||||
expect(getSubscriptionTierKey('PRO')).toBe('pro')
|
||||
expect(getSubscriptionTierKey('FREE')).toBe('free')
|
||||
expect(getSubscriptionTierKey('FOUNDERS_EDITION')).toBe('founder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSubscriptionDate', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatSubscriptionDate(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(formatSubscriptionDate(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('formats a date string', () => {
|
||||
const result = formatSubscriptionDate('2026-06-15T12:00:00Z')
|
||||
expect(result).toContain('Jun')
|
||||
expect(result).toContain('2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRefillsDate', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatRefillsDate(null)).toBe('')
|
||||
})
|
||||
|
||||
it('formats as MM/DD/YY using UTC (timezone-agnostic)', () => {
|
||||
// Input has explicit `Z` (UTC); formatRefillsDate uses UTC methods,
|
||||
// so the result is stable across local timezones.
|
||||
const result = formatRefillsDate('2026-06-15T12:00:00Z')
|
||||
expect(result).toBe('06/15/26')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextMonthInvoice', () => {
|
||||
it('multiplies member count by tier price', () => {
|
||||
expect(getNextMonthInvoice(3, 20)).toBe(60)
|
||||
})
|
||||
|
||||
it('returns 0 for zero members', () => {
|
||||
expect(getNextMonthInvoice(0, 20)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlanTotalCreditsValue', () => {
|
||||
it('returns monthly credits for standard tier', () => {
|
||||
expect(getPlanTotalCreditsValue('standard', false)).toBe(4200)
|
||||
})
|
||||
|
||||
it('returns yearly credits (12x) for standard tier', () => {
|
||||
expect(getPlanTotalCreditsValue('standard', true)).toBe(50400)
|
||||
})
|
||||
|
||||
it('returns creator tier credits', () => {
|
||||
expect(getPlanTotalCreditsValue('creator', false)).toBe(7400)
|
||||
})
|
||||
|
||||
it('returns pro tier credits', () => {
|
||||
expect(getPlanTotalCreditsValue('pro', false)).toBe(21100)
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
TierKey
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
export function getSubscriptionTierKey(
|
||||
tier: SubscriptionTier | null | undefined
|
||||
): TierKey {
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
}
|
||||
|
||||
export function formatSubscriptionDate(date?: string | null): string {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
export function formatRefillsDate(date?: string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const year = String(d.getUTCFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
}
|
||||
|
||||
export function getNextMonthInvoice(
|
||||
memberCount: number,
|
||||
tierPrice: number
|
||||
): number {
|
||||
return memberCount * tierPrice
|
||||
}
|
||||
|
||||
export function getPlanTotalCreditsValue(
|
||||
tierKey: TierKey,
|
||||
isYearly: boolean
|
||||
): number | null {
|
||||
const credits = getTierCredits(tierKey)
|
||||
if (credits === null) return null
|
||||
return isYearly ? credits * 12 : credits
|
||||
}
|
||||
88
src/utils/executionUtil.test.ts
Normal file
88
src/utils/executionUtil.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { graphToPrompt } from './executionUtil'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('graphToPrompt', () => {
|
||||
it('excludes nodes with isVirtualNode from API output', async () => {
|
||||
const graph = new LGraph()
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const virtualNode = new LGraphNode('VirtualNode')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.comfyClass = 'Note'
|
||||
graph.add(virtualNode)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(output[String(virtualNode.id)]).toBeUndefined()
|
||||
expect(output[String(realNode.id)]).toBeDefined()
|
||||
expect(output[String(realNode.id)].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
it('produces empty output when all nodes are virtual', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const mdNote = new LGraphNode('MarkdownNote')
|
||||
mdNote.isVirtualNode = true
|
||||
mdNote.comfyClass = 'MarkdownNote'
|
||||
graph.add(mdNote)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes virtual nodes in workflow JSON for save fidelity', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const { workflow, output } = await graphToPrompt(graph)
|
||||
|
||||
expect(
|
||||
workflow.nodes.some((n) => n.id === note.id),
|
||||
'Workflow JSON should preserve virtual nodes by ID'
|
||||
).toBe(true)
|
||||
expect(output[String(note.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves multiple non-virtual nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const node1 = new LGraphNode('Node1')
|
||||
node1.comfyClass = 'KSampler'
|
||||
graph.add(node1)
|
||||
|
||||
const node2 = new LGraphNode('Node2')
|
||||
node2.comfyClass = 'SaveImage'
|
||||
graph.add(node2)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(2)
|
||||
expect(output[String(node1.id)].class_type).toBe('KSampler')
|
||||
expect(output[String(node2.id)].class_type).toBe('SaveImage')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user