Compare commits

..

3 Commits

Author SHA1 Message Date
Alexis Rolland
516a2b02bb Merge branch 'main' into alexis/update_default_workflow 2026-06-12 06:42:52 +08:00
Alexis Rolland
eb4d111a73 Update default graph 2026-06-12 06:37:45 +08:00
Alexis Rolland
8c66843f6e Update default workflow 2026-06-11 22:54:03 +08:00
22 changed files with 421 additions and 605 deletions

View File

@@ -137,8 +137,7 @@ export const TestIds = {
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button',
bypass: 'bypass-button'
convertSubgraph: 'convert-to-subgraph-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -129,18 +129,23 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// A group + a KSampler node
await comfyPage.workflow.loadWorkflow('groups/single_group')
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
// Select group + node should show bypass button
await comfyPage.canvas.focus()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(bypass).toBeVisible()
await comfyPage.keyboard.delete()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// (Only empty group is selected) should hide bypass button
await comfyPage.keyboard.selectAll()
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(bypass).toBeHidden()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeHidden()
})
test.describe('Color Picker', () => {

View File

@@ -3,8 +3,6 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
const CREATE_GROUP_HOTKEY = 'Control+g'
@@ -219,40 +217,4 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
)
}).toPass({ timeout: 5000 })
})
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('.')
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const toggleBypass = () =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
const bypassCount = () =>
comfyPage.page.evaluate(
() => graph!.nodes.filter((node) => node.mode === 4).length
)
expect(await bypassCount()).toBe(0)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.select()
await toggleBypass()
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
await comfyPage.page.keyboard.down('Shift')
await ksampler.select()
await comfyPage.page.keyboard.up('Shift')
await toggleBypass()
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})

View File

@@ -101,7 +101,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const {
hasAnySelection,
hasGroupedNodesSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
@@ -119,10 +118,7 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value ||
isSingleSubgraph.value ||
hasMultipleSelection.value ||
hasGroupedNodesSelection.value
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)

View File

@@ -2,9 +2,6 @@ import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
@@ -19,9 +16,7 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
renewalDate: string | null
/** ISO 8601 */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -49,9 +44,6 @@ export interface BillingActions {
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
@@ -73,12 +65,16 @@ export interface BillingState {
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
billingStatus: ComputedRef<BillingStatus | null>
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -5,17 +5,13 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
}))
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
@@ -54,9 +50,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
subscriptionStatus: {
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
},
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
@@ -75,12 +70,6 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mockPurchaseCredits
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
@@ -140,7 +129,7 @@ describe('useBillingContext', () => {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: '2025-01-01T00:00:00Z',
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
@@ -184,13 +173,6 @@ describe('useBillingContext', () => {
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
const { topup } = useBillingContext()
await topup(500)
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)

View File

@@ -122,15 +122,6 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -227,14 +218,6 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.cancelSubscription()
}
async function resubscribe() {
return activeContext.value.resubscribe()
}
async function topup(amountCents: number) {
return activeContext.value.topup(amountCents)
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
@@ -258,10 +241,6 @@ function useBillingContextInternal(): BillingContext {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
getMaxSeats,
initialize,
@@ -271,8 +250,6 @@ function useBillingContextInternal(): BillingContext {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,10 +1,7 @@
import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -27,7 +24,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
subscriptionStatus: legacySubscriptionStatus,
formattedRenewalDate,
formattedEndDate,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
@@ -36,7 +34,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
} = useSubscription()
const authStore = useAuthStore()
const authActions = useAuthActions()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -55,8 +52,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
endDate: legacySubscriptionStatus.value?.end_date ?? null,
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
@@ -78,18 +75,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
}
})
// Legacy has no coarse billing_status concept (workspace-only).
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
@@ -167,16 +152,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function resubscribe(): Promise<void> {
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
await legacySubscribe()
}
async function topup(amountCents: number): Promise<void> {
// Facade standardizes on cents; legacy /customers/credit takes dollars.
await authActions.purchaseCredits(amountCents / 100)
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
@@ -204,10 +179,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -217,8 +188,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,10 +1,8 @@
import { uniq } from 'es-toolkit'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -73,13 +71,7 @@ export function useSelectedLiteGraphItems() {
* the prior null-tolerance for callers wired to early-firing commands.
*/
const getSelectedNodesShallow = (): LGraphNode[] =>
uniq(
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
if (isLGraphNode(item)) return [item]
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
return []
})
)
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.

View File

@@ -7,12 +7,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
@@ -46,11 +41,6 @@ export function useSelectionState() {
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const hasGroupedNodesSelection = computed(() =>
selectedItems.value.some(
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
)
)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
@@ -122,7 +112,6 @@ export function useSelectionState() {
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasGroupedNodesSelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,

View File

@@ -147,8 +147,7 @@ describe('OAuthConsentView', () => {
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace',
expectedRedirectUri: 'http://127.0.0.1:50632/cb'
workspaceId: 'personal-workspace'
})
})

View File

@@ -283,8 +283,7 @@ async function submit(decision: 'allow' | 'deny') {
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId,
expectedRedirectUri: challenge.value.redirect_uri
workspaceId
})
clearOAuthRequestId()
} catch (error) {

View File

@@ -220,111 +220,6 @@ describe('submitOAuthConsentDecision', () => {
).rejects.toThrow('redirect_url')
})
it('navigates to a reverse-DNS custom-scheme redirect_url (native clients)', async () => {
// RFC 8252 native-app callback — the comfy-ios client returns the
// authorization code via org.comfy.ios://oauth-callback. The backend
// has already validated the URL byte-identically against the client's
// registered redirect_uris.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
redirect_url: 'org.comfy.ios://oauth-callback?code=xyz&state=s'
})
)
const originalLocation = globalThis.location
const hrefSetter = vi.fn()
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new Proxy(originalLocation, {
set(_target, prop, value) {
if (prop === 'href') {
hrefSetter(value)
return true
}
return Reflect.set(originalLocation, prop, value)
},
get(_target, prop) {
return Reflect.get(originalLocation, prop)
}
})
})
try {
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace',
expectedRedirectUri: 'org.comfy.ios://oauth-callback'
})
expect(hrefSetter).toHaveBeenCalledWith(
'org.comfy.ios://oauth-callback?code=xyz&state=s'
)
expect(hrefSetter).toHaveBeenCalledTimes(1)
} finally {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation
})
}
})
it.for([
[
'org.comfy.ios://oauth-callback?code=xyz',
undefined,
'unsafe scheme',
'custom scheme with no expectedRedirectUri is unbindable, falls back to the http(s)-only rule'
],
[
'com.evil.app://oauth-callback?code=xyz',
'org.comfy.ios://oauth-callback',
'does not match',
'bound challenge, different scheme: wrong-client redirect'
],
[
'org.comfy.ios://oauth-callback/../steal?code=xyz',
'org.comfy.ios://oauth-callback',
'does not match',
'bound challenge, same scheme but different path'
],
[
'javascript:alert(1)',
'javascript:alert(1)',
'unsafe scheme',
'executable schemes are rejected even if the challenge claims them'
],
[
'data:text/html,<script>alert(1)</script>',
'data:text/html,x',
'unsafe scheme',
'data: scheme rejected even if the challenge claims it'
],
[
'blob:https://cloud.comfy.org/abc',
undefined,
'unsafe scheme',
'blob: scheme is unsafe'
]
] as const)(
'rejects redirect_url %s (registration %s, expects %s): %s',
async ([redirectUrl, expectedRedirectUri, expectedError]) => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: redirectUrl })
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace',
expectedRedirectUri
})
).rejects.toThrow(expectedError)
}
)
it('rejects an unsafe redirect_url scheme', async () => {
// Defense in depth: even though the cloud backend is trusted, never
// hand the browser off to a non-http(s) URL.

View File

@@ -40,33 +40,12 @@ export type OAuthConsentDecisionParams = {
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
/**
* The challenge's registered `redirect_uri`. When present, the
* post-consent navigation must match it (scheme, authority, path) —
* the server only appends `code`/`state` query params to the
* registered URI, so any other destination is rejected. When absent
* (challenges from backends that don't surface it yet), only http(s)
* redirects are navigable.
*/
expectedRedirectUri?: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
// Schemes that execute in our origin if navigated. Never navigable,
// regardless of what the backend returns. Everything else is governed
// by binding to the challenge's registered redirect_uri — no per-client
// scheme knowledge lives in the frontend.
const EXECUTABLE_SCHEMES: ReadonlySet<string> = new Set([
'javascript:',
'data:',
'blob:',
'vbscript:',
'about:'
])
export class OAuthApiError extends Error {
constructor(
message: string,
@@ -139,8 +118,7 @@ export async function submitOAuthConsentDecision({
oauthRequestId,
csrfToken,
decision,
workspaceId,
expectedRedirectUri
workspaceId
}: OAuthConsentDecisionParams): Promise<void> {
const response = await fetch('/oauth/authorize', {
method: 'POST',
@@ -166,56 +144,13 @@ export async function submitOAuthConsentDecision({
throw new Error('OAuth consent response did not include redirect_url')
}
// Defense in depth at this sink. Two risks: schemes that execute in our
// origin (always rejected, below), and the OS routing the authorization
// code + state to whichever installed app claims an arbitrary custom
// scheme. For the latter we hold the navigation to the redirect the
// backend registered for THIS auth request (the challenge's
// redirect_uri): the server only ever appends code/state query params
// to the registered URI, so scheme, authority, and path must match
// exactly. No per-client scheme list lives in the frontend — new native
// clients need only their backend registration.
const parseTarget = () => {
try {
return new URL(redirectUrl, globalThis.location.origin)
} catch (err) {
throw new Error('OAuth consent redirect_url is not a valid URL', {
cause: err
})
}
}
const target = parseTarget()
if (EXECUTABLE_SCHEMES.has(target.protocol)) {
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
if (expectedRedirectUri) {
const parseExpected = () => {
try {
return new URL(expectedRedirectUri)
} catch (err) {
throw new Error(
'OAuth consent challenge redirect_uri is not a valid URL',
{ cause: err }
)
}
}
const expected = parseExpected()
const matchesRegistration =
target.protocol === expected.protocol &&
target.host === expected.host &&
target.pathname === expected.pathname
if (!matchesRegistration) {
throw new Error(
'OAuth consent redirect_url does not match the registered redirect_uri'
)
}
} else if (target.protocol !== 'http:' && target.protocol !== 'https:') {
// Challenges that don't surface redirect_uri can't be bound; hold the
// pre-existing http(s)-only line for them.
// Defense in depth: even though the cloud backend is trusted, never hand
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
// execute in our origin.
const target = new URL(redirectUrl, globalThis.location.origin)
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
// Navigate the parsed URL, not the raw string, so the value validated
// above is byte-for-byte the value the browser receives.
globalThis.location.href = target.href
globalThis.location.href = redirectUrl
}

View File

@@ -196,13 +196,9 @@ export interface PreviewSubscribeResponse {
new_plan: PreviewPlanInfo
}
export type BillingSubscriptionStatus =
| 'active'
| 'scheduled'
| 'ended'
| 'canceled'
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
export type BillingStatus =
type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
@@ -237,7 +233,7 @@ interface CreateTopupRequest {
type TopupStatus = 'pending' | 'completed' | 'failed'
export interface CreateTopupResponse {
interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus

View File

@@ -371,6 +371,7 @@ 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,
@@ -403,8 +404,7 @@ const {
manageSubscription,
fetchStatus,
fetchBalance,
getMaxSeats,
resubscribe
getMaxSeats
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
@@ -415,12 +415,13 @@ const isResubscribing = ref(false)
async function handleResubscribe() {
isResubscribing.value = true
try {
await resubscribe()
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'

View File

@@ -161,6 +161,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -176,7 +177,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance, topup } = useBillingContext()
const { fetchBalance } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -256,8 +257,7 @@ async function handleBuy() {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await topup(amountCents)
if (!response) return
const response = await workspaceApi.createTopup(amountCents)
if (response.status === 'completed') {
toast.add({

View File

@@ -91,12 +91,10 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
previewSubscribe: mockPreviewSubscribe,
plans: computed(() => mockPlans.value),
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance,
resubscribe: mockResubscribe
fetchBalance: mockFetchBalance
})
}))
// Shields the test from the real workspaceApi → @/scripts/api → app.ts import chain
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { resubscribe: mockResubscribe }
}))

View File

@@ -11,6 +11,7 @@ import type {
Plan,
PreviewSubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
type CheckoutStep = 'pricing' | 'preview'
@@ -34,14 +35,8 @@ export function useSubscriptionCheckout(emit: {
}) {
const { t } = useI18n()
const toast = useToast()
const {
subscribe,
previewSubscribe,
plans,
fetchStatus,
fetchBalance,
resubscribe
} = useBillingContext()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const telemetry = useTelemetry()
const billingOperationStore = useBillingOperationStore()
@@ -175,12 +170,13 @@ export function useSubscriptionCheckout(emit: {
async function handleResubscribe() {
isResubscribing.value = true
try {
await resubscribe()
await workspaceApi.resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} catch (error) {
const message =

View File

@@ -11,9 +11,7 @@ const mockWorkspaceApi = vi.hoisted(() => ({
subscribe: vi.fn(),
previewSubscribe: vi.fn(),
getPaymentPortalUrl: vi.fn(),
cancelSubscription: vi.fn(),
resubscribe: vi.fn(),
createTopup: vi.fn()
cancelSubscription: vi.fn()
}))
const mockBillingPlans = vi.hoisted(() => ({
@@ -624,90 +622,6 @@ describe('useWorkspaceBilling', () => {
})
})
describe('resubscribe', () => {
it('refreshes status and balance after a successful resubscribe', async () => {
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
const billing = setupBilling()
await billing.resubscribe()
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
expect(billing.subscription.value?.tier).toBe('CREATOR')
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue(
new Error('reactivation failed')
)
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
expect(billing.error.value).toBe('reactivation failed')
expect(billing.isLoading.value).toBe(false)
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to resubscribe')
})
})
describe('topup', () => {
const topupResponse = {
billing_op_id: 'op-topup',
topup_id: 'topup-1',
status: 'completed' as const,
amount_cents: 500
}
it('returns the createTopup response without refreshing status or balance', async () => {
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
const billing = setupBilling()
const result = await billing.topup(500)
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
expect(result).toBe(topupResponse)
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error and rethrows when the API call fails', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toThrow('card declined')
expect(billing.error.value).toBe('card declined')
expect(billing.isLoading.value).toBe(false)
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to top up credits')
})
})
describe('plans / currentPlanSlug / fetchPlans', () => {
it('prefers the plan slug from status over the billingPlans fallback', async () => {
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'

View File

@@ -5,7 +5,6 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
import type {
BillingBalanceResponse,
BillingStatusResponse,
CreateTopupResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -71,13 +70,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
})
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
const subscriptionStatus = computed(
() => statusData.value?.subscription_status ?? null
)
const tier = computed(() => statusData.value?.subscription_tier ?? null)
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
@@ -270,34 +262,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
}
async function resubscribe(): Promise<void> {
isLoading.value = true
error.value = null
try {
await workspaceApi.resubscribe()
await Promise.all([fetchStatus(), fetchBalance()])
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
throw err
} finally {
isLoading.value = false
}
}
async function topup(amountCents: number): Promise<CreateTopupResponse> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.createTopup(amountCents)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to top up credits'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
@@ -339,10 +303,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -352,8 +312,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,149 +1,407 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
export const defaultGraph: ComfyWorkflowJSON = {
last_node_id: 9,
last_link_id: 9,
last_node_id: 71,
last_link_id: 82,
nodes: [
{
id: 7,
type: 'CLIPTextEncode',
pos: [413, 389],
size: [425.27801513671875, 180.6060791015625],
id: 9,
type: 'SaveImage',
pos: [1279.9999726783708, 319.9999392082668],
size: [300, 420],
flags: {},
order: 3,
order: 9,
mode: 0,
inputs: [{ name: 'clip', type: 'CLIP', link: 5 }],
outputs: [
inputs: [
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [6],
slot_index: 0
name: 'images',
type: 'IMAGE',
link: 80
}
],
properties: {},
widgets_values: ['text, watermark']
outputs: [],
properties: {
'Node name for S&R': 'SaveImage',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['ComfyUI']
},
{
id: 6,
type: 'CLIPTextEncode',
pos: [415, 186],
size: [422.84503173828125, 164.31304931640625],
id: 62,
type: 'CLIPLoader',
pos: [-239.9999987113997, 420.0000536491848],
size: [340, 169.3125],
flags: {},
order: 2,
order: 0,
mode: 0,
inputs: [{ name: 'clip', type: 'CLIP', link: 3 }],
inputs: [],
outputs: [
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [4],
slot_index: 0
name: 'CLIP',
type: 'CLIP',
links: [79, 81]
}
],
properties: {},
widgets_values: [
'beautiful scenery nature glass bottle landscape, , purple galaxy bottle,'
]
properties: {
'Node name for S&R': 'CLIPLoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'qwen_3_4b.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors',
directory: 'text_encoders'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['qwen_3_4b.safetensors', 'lumina2', 'default']
},
{
id: 5,
type: 'EmptyLatentImage',
pos: [473, 609],
size: [315, 106],
id: 63,
type: 'VAELoader',
pos: [659.9998200904802, 699.9998629143215],
size: [320, 106.65625],
flags: {},
order: 1,
mode: 0,
outputs: [{ name: 'LATENT', type: 'LATENT', links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1]
inputs: [],
outputs: [
{
name: 'VAE',
type: 'VAE',
links: [74]
}
],
properties: {
'Node name for S&R': 'VAELoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'ae.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors',
directory: 'vae'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['ae.safetensors']
},
{
id: 3,
type: 'KSampler',
pos: [863, 186],
size: [315, 262],
id: 65,
type: 'VAEDecode',
pos: [1019.9998200904802, 319.9999392082668],
size: [225, 96],
flags: {},
order: 8,
mode: 0,
inputs: [
{
name: 'samples',
type: 'LATENT',
link: 73
},
{
name: 'vae',
type: 'VAE',
link: 74
}
],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
slot_index: 0,
links: [80]
}
],
properties: {
'Node name for S&R': 'VAEDecode',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: []
},
{
id: 66,
type: 'UNETLoader',
pos: [-239.9999987113997, 110.00000596546897],
size: [380, 134.65625],
flags: {},
order: 2,
mode: 0,
inputs: [],
outputs: [
{
name: 'MODEL',
type: 'MODEL',
links: [72]
}
],
properties: {
'Node name for S&R': 'UNETLoader',
cnr_id: 'comfy-core',
ver: '0.3.73',
models: [
{
name: 'z_image_turbo_bf16.safetensors',
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors',
directory: 'diffusion_models'
}
],
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['z_image_turbo_bf16.safetensors', 'default']
},
{
id: 67,
type: 'CLIPTextEncode',
pos: [170.00001082534345, 290.0000536491848],
size: [410, 160],
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: 'model', type: 'MODEL', link: 1 },
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 },
{ name: 'latent_image', type: 'LATENT', link: 2 }
{
name: 'clip',
type: 'CLIP',
link: 79
}
],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, 'euler', 'normal', 1]
outputs: [
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [76]
}
],
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.73',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [
'anime RPG game style, cute anime girl with gigantic fennec ears and a big fluffy fox tail with long wavy blonde hair and large blue eyes blonde colored eyelashes wearing a pink sweater a large oversized gold trimmed black winter coat and a long blue maxi skirt and a red scarf, she is sitting beside a campfire in the wilderness at night playing guitar with a milky way galaxy sky'
]
},
{
id: 8,
type: 'VAEDecode',
pos: [1209, 188],
size: [210, 46],
id: 68,
type: 'EmptySD3LatentImage',
pos: [310.0000489723161, 700.0000155022121],
size: [260, 168],
flags: {},
order: 3,
mode: 0,
inputs: [],
outputs: [
{
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: [78]
}
],
properties: {
'Node name for S&R': 'EmptySD3LatentImage',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [1024, 1024, 1]
},
{
id: 69,
type: 'ModelSamplingAuraFlow',
pos: [220.00001082534345, 110.00000596546897],
size: [310, 104],
flags: {},
order: 6,
mode: 0,
inputs: [
{
name: 'model',
type: 'MODEL',
link: 72
}
],
outputs: [
{
name: 'MODEL',
type: 'MODEL',
slot_index: 0,
links: [75]
}
],
properties: {
'Node name for S&R': 'ModelSamplingAuraFlow',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [3]
},
{
id: 70,
type: 'KSampler',
pos: [659.9998200904802, 319.9999392082668],
size: [315, 341.3125],
flags: {},
order: 7,
mode: 0,
inputs: [
{
name: 'model',
type: 'MODEL',
link: 75
},
{
name: 'positive',
type: 'CONDITIONING',
link: 76
},
{
name: 'negative',
type: 'CONDITIONING',
link: 82
},
{
name: 'latent_image',
type: 'LATENT',
link: 78
}
],
outputs: [
{
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: [73]
}
],
properties: {
'Node name for S&R': 'KSampler',
cnr_id: 'comfy-core',
ver: '0.3.64',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: [42, 'fixed', 8, 1, 'res_multistep', 'simple', 1]
},
{
id: 71,
type: 'CLIPTextEncode',
pos: [170.00001082534345, 520.0000536491848],
size: [405.46875, 140],
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: 'samples', type: 'LATENT', link: 7 },
{ name: 'vae', type: 'VAE', link: 8 }
{
name: 'clip',
type: 'CLIP',
link: 81
}
],
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [9], slot_index: 0 }],
properties: {}
},
{
id: 9,
type: 'SaveImage',
pos: [1451, 189],
size: [210, 26],
flags: {},
order: 6,
mode: 0,
inputs: [{ name: 'images', type: 'IMAGE', link: 9 }],
properties: {}
},
{
id: 4,
type: 'CheckpointLoaderSimple',
pos: [26, 474],
size: [315, 98],
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: 'MODEL', type: 'MODEL', links: [1], slot_index: 0 },
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
{
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [82]
}
],
properties: {
models: [
{
name: 'v1-5-pruned-emaonly-fp16.safetensors',
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors',
directory: 'checkpoints'
}
]
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.73',
enableTabs: false,
tabWidth: 65,
tabXOffset: 10,
hasSecondTab: false,
secondTabText: 'Send Back',
secondTabOffset: 80,
secondTabWidth: 65
},
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
widgets_values: [
'low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs'
]
}
],
links: [
[1, 4, 0, 3, 0, 'MODEL'],
[2, 5, 0, 3, 3, 'LATENT'],
[3, 4, 1, 6, 0, 'CLIP'],
[4, 6, 0, 3, 1, 'CONDITIONING'],
[5, 4, 1, 7, 0, 'CLIP'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[7, 3, 0, 8, 0, 'LATENT'],
[8, 4, 2, 8, 1, 'VAE'],
[9, 8, 0, 9, 0, 'IMAGE']
[72, 66, 0, 69, 0, 'MODEL'],
[73, 70, 0, 65, 0, 'LATENT'],
[74, 63, 0, 65, 1, 'VAE'],
[75, 69, 0, 70, 0, 'MODEL'],
[76, 67, 0, 70, 1, 'CONDITIONING'],
[78, 68, 0, 70, 3, 'LATENT'],
[79, 62, 0, 67, 0, 'CLIP'],
[80, 65, 0, 9, 0, 'IMAGE'],
[81, 62, 0, 71, 0, 'CLIP'],
[82, 71, 0, 70, 2, 'CONDITIONING']
],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
scale: 0.9,
offset: [416, 110]
}
},
version: 0.4