Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Brown
c52fa622ea Merge branch 'main' into glary/test-note-node-api-export 2026-05-19 14:01:41 -07:00
Glary-Bot
a065087e2f test: add tests for virtual node filtering in API export
Add E2E and unit tests verifying that virtual nodes (Note,
MarkdownNote, Reroute, PrimitiveNode) are excluded from API format
export while preserved in standard workflow format.

Unit tests confirm graphToPrompt correctly filters isVirtualNode
from the output object. E2E tests verify the same through the
browser via getExportedWorkflow({ api: true }).

Includes a new test fixture (note_with_ksampler.json) combining
real and virtual nodes for mixed-workflow assertions.
2026-04-19 00:53:03 +00:00
10 changed files with 433 additions and 456 deletions

View 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
}

View 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)
})
})

View File

@@ -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}."

View 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)
})
})

View File

@@ -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
}
}

View File

@@ -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')
})
})

View File

@@ -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)
}

View File

@@ -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)
})
})

View File

@@ -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
}

View 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')
})
})