diff --git a/browser_tests/tests/subscriptionPaywallError.spec.ts b/browser_tests/tests/subscriptionPaywallError.spec.ts new file mode 100644 index 00000000000..f900bb74894 --- /dev/null +++ b/browser_tests/tests/subscriptionPaywallError.spec.ts @@ -0,0 +1,68 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import type { PromptResponse } from '@/schemas/apiSchema' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +// Regression for #12840: a free-tier paywall on queue (`POST /prompt` 402 with +// `{ error: { type: 'PAYMENT_REQUIRED', message: 'Subscription required to +// queue workflows' } }`) is an account precondition. It must open its own modal +// and stay out of the error panel, instead of surfacing the raw backend string +// with non-actionable Find-on-GitHub / Copy actions. +test.describe('Subscription paywall on queue', { tag: '@ui' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.RightSidePanel.ShowErrorsTab', + true + ) + }) + + async function mockQueueError(page: Page, error: PromptResponse['error']) { + const body: PromptResponse = { node_errors: {}, error } + await page.route('**/api/prompt', async (route) => { + await route.fulfill({ + status: 402, + contentType: 'application/json', + body: JSON.stringify(body) + }) + }) + } + + test('keeps the subscription paywall out of the error panel', async ({ + comfyPage + }) => { + await mockQueueError(comfyPage.page, { + type: 'PAYMENT_REQUIRED', + message: 'Subscription required to queue workflows', + details: '' + }) + + const queued = comfyPage.page.waitForResponse('**/api/prompt') + await comfyPage.actionbar.queueButton.primaryButton.click() + await queued + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeHidden() + }) + + test('still surfaces ordinary queue errors in the error panel', async ({ + comfyPage + }) => { + await mockQueueError(comfyPage.page, { + type: 'server_error', + message: 'The server exploded', + details: '' + }) + + const queued = comfyPage.page.waitForResponse('**/api/prompt') + await comfyPage.actionbar.queueButton.primaryButton.click() + await queued + + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).toBeVisible() + }) +}) diff --git a/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.test.ts b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.test.ts new file mode 100644 index 00000000000..64f102bf8dd --- /dev/null +++ b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useAccountPreconditionDialog } from './useAccountPreconditionDialog' + +const mockDialogService = { + showApiNodesSignInDialog: vi.fn(), + showSubscriptionRequiredDialog: vi.fn(), + showTopUpCreditsDialog: vi.fn() +} + +vi.mock('@/services/dialogService', () => ({ + useDialogService: vi.fn(() => mockDialogService) +})) + +describe('useAccountPreconditionDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes a sign-in precondition to the API sign-in dialog with the node type', () => { + useAccountPreconditionDialog().open('sign_in', { nodeType: 'ApiNode' }) + + expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([ + 'ApiNode' + ]) + expect( + mockDialogService.showSubscriptionRequiredDialog + ).not.toHaveBeenCalled() + expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled() + }) + + it('routes a sign-in precondition with no node type to an empty list', () => { + useAccountPreconditionDialog().open('sign_in') + + expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([]) + }) + + it('routes a subscription precondition to the subscription dialog', () => { + useAccountPreconditionDialog().open('subscription') + + expect( + mockDialogService.showSubscriptionRequiredDialog + ).toHaveBeenCalledTimes(1) + expect(mockDialogService.showApiNodesSignInDialog).not.toHaveBeenCalled() + expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled() + }) + + it('routes a credit precondition to the top-up dialog', () => { + useAccountPreconditionDialog().open('credits', { nodeType: 'PartnerNode' }) + + expect(mockDialogService.showTopUpCreditsDialog).toHaveBeenCalledWith({ + isInsufficientCredits: true + }) + expect( + mockDialogService.showSubscriptionRequiredDialog + ).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts new file mode 100644 index 00000000000..1828bd2e2cb --- /dev/null +++ b/src/platform/cloud/subscription/composables/useAccountPreconditionDialog.ts @@ -0,0 +1,38 @@ +import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting' +import { useDialogService } from '@/services/dialogService' + +export interface AccountPreconditionContext { + /** Node type that triggered the precondition, used as modal context. */ + nodeType?: string +} + +// Routes a resolved account precondition to its dedicated modal. This is the +// single seam where FE-978 attaches role-aware (member vs owner) subscription +// content: the `subscription` branch resolves to the subscription dialog, whose +// inner content FE-978 specializes for cancelled/inactive team states. +export function useAccountPreconditionDialog() { + const dialogService = useDialogService() + + function open( + precondition: AccountPrecondition, + context: AccountPreconditionContext = {} + ): void { + switch (precondition) { + case 'sign_in': + void dialogService.showApiNodesSignInDialog( + context.nodeType ? [context.nodeType] : [] + ) + return + case 'subscription': + void dialogService.showSubscriptionRequiredDialog() + return + case 'credits': + void dialogService.showTopUpCreditsDialog({ + isInsufficientCredits: true + }) + return + } + } + + return { open } +} diff --git a/src/platform/errorCatalog/accountPreconditionRouting.test.ts b/src/platform/errorCatalog/accountPreconditionRouting.test.ts new file mode 100644 index 00000000000..3082f902861 --- /dev/null +++ b/src/platform/errorCatalog/accountPreconditionRouting.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { + isAccountPreconditionCatalogId, + preconditionForCatalogId, + resolveAccountPrecondition +} from './accountPreconditionRouting' +import { + EXECUTION_FAILED_CATALOG_ID, + INSUFFICIENT_CREDITS_CATALOG_ID, + SIGN_IN_REQUIRED_CATALOG_ID, + SUBSCRIPTION_REQUIRED_CATALOG_ID, + SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, + WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID +} from './catalogIds' + +describe('resolveAccountPrecondition', () => { + it('classifies a sign-in error', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'RuntimeError', + exceptionMessage: 'Unauthorized: Please login first to use this node.' + }) + ).toBe('sign_in') + }) + + it('classifies an inactive-subscription error', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'InactiveSubscriptionError', + exceptionMessage: + 'User has no active subscription. Please subscribe to a plan to continue.' + }) + ).toBe('subscription') + }) + + it('classifies the queue paywall (PAYMENT_REQUIRED) as a subscription precondition', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'PAYMENT_REQUIRED', + exceptionMessage: 'Subscription required to queue workflows' + }) + ).toBe('subscription') + }) + + it('classifies a subscription-upgrade error as a subscription precondition', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'RuntimeError', + exceptionMessage: + 'the following private models require a subscription upgrade: flux-pro' + }) + ).toBe('subscription') + }) + + it('classifies an account credit error', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'InsufficientFundsError', + exceptionMessage: + 'Payment Required: Please add credits to your account to use this node.' + }) + ).toBe('credits') + }) + + it('classifies a workspace credit error', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'RuntimeError', + exceptionMessage: + 'Payment Required: Please add credits to your workspace to continue.' + }) + ).toBe('credits') + }) + + it('returns undefined for an ordinary workflow error', () => { + expect( + resolveAccountPrecondition({ + exceptionType: 'RuntimeError', + exceptionMessage: 'CUDA out of memory' + }) + ).toBeUndefined() + }) +}) + +describe('preconditionForCatalogId / isAccountPreconditionCatalogId', () => { + it('maps every precondition catalog id', () => { + expect(preconditionForCatalogId(SIGN_IN_REQUIRED_CATALOG_ID)).toBe( + 'sign_in' + ) + expect(preconditionForCatalogId(SUBSCRIPTION_REQUIRED_CATALOG_ID)).toBe( + 'subscription' + ) + expect( + preconditionForCatalogId(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID) + ).toBe('subscription') + expect(preconditionForCatalogId(INSUFFICIENT_CREDITS_CATALOG_ID)).toBe( + 'credits' + ) + expect( + preconditionForCatalogId(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID) + ).toBe('credits') + }) + + it('does not treat a workflow error catalog id as a precondition', () => { + expect( + preconditionForCatalogId(EXECUTION_FAILED_CATALOG_ID) + ).toBeUndefined() + expect(isAccountPreconditionCatalogId(EXECUTION_FAILED_CATALOG_ID)).toBe( + false + ) + expect(isAccountPreconditionCatalogId(undefined)).toBe(false) + }) +}) diff --git a/src/platform/errorCatalog/accountPreconditionRouting.ts b/src/platform/errorCatalog/accountPreconditionRouting.ts new file mode 100644 index 00000000000..a3f6338268d --- /dev/null +++ b/src/platform/errorCatalog/accountPreconditionRouting.ts @@ -0,0 +1,44 @@ +import { + INSUFFICIENT_CREDITS_CATALOG_ID, + SIGN_IN_REQUIRED_CATALOG_ID, + SUBSCRIPTION_REQUIRED_CATALOG_ID, + SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, + WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID +} from './catalogIds' +import type { RuntimeErrorInfo } from './runtimeErrorMatcher' +import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher' + +// Account preconditions are gating states (sign-in, subscription, credits) that +// must open their own modal instead of surfacing as a workflow error. They are +// excluded from the error panel and the error count. +export type AccountPrecondition = 'sign_in' | 'subscription' | 'credits' + +const CATALOG_ID_TO_PRECONDITION = new Map([ + [SIGN_IN_REQUIRED_CATALOG_ID, 'sign_in'], + [SUBSCRIPTION_REQUIRED_CATALOG_ID, 'subscription'], + [SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, 'subscription'], + [INSUFFICIENT_CREDITS_CATALOG_ID, 'credits'], + [WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID, 'credits'] +]) + +export function preconditionForCatalogId( + catalogId: string | undefined +): AccountPrecondition | undefined { + if (!catalogId) return undefined + return CATALOG_ID_TO_PRECONDITION.get(catalogId) +} + +export function isAccountPreconditionCatalogId( + catalogId: string | undefined +): boolean { + return preconditionForCatalogId(catalogId) !== undefined +} + +// Classifies a single runtime error payload into the account precondition it +// represents, or `undefined` when it is an ordinary workflow error. +export function resolveAccountPrecondition( + info: RuntimeErrorInfo +): AccountPrecondition | undefined { + const match = resolveRuntimeCatalogMatch(info) + return preconditionForCatalogId(match?.catalogId) +} diff --git a/src/platform/errorCatalog/runtimeErrorMatcher.ts b/src/platform/errorCatalog/runtimeErrorMatcher.ts index 76e13ef811e..933b3751236 100644 --- a/src/platform/errorCatalog/runtimeErrorMatcher.ts +++ b/src/platform/errorCatalog/runtimeErrorMatcher.ts @@ -37,7 +37,8 @@ const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([ ]) const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([ 'Workspace has no active subscription. Please subscribe to a plan to continue.', - 'User has no active subscription. Please subscribe to a plan to continue.' + 'User has no active subscription. Please subscribe to a plan to continue.', + 'Subscription required to queue workflows' ]) const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX = 'the following private models require a subscription upgrade:' @@ -120,7 +121,7 @@ const PREPROCESSING_FAILED_PREFIXES = [ 'Failed to complete preparation:' ] -interface RuntimeErrorInfo { +export interface RuntimeErrorInfo { exceptionType: string exceptionMessage: string } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 2345b0871ae..41ae0e73181 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -60,6 +60,8 @@ import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget' +import { useAccountPreconditionDialog } from '@/platform/cloud/subscription/composables/useAccountPreconditionDialog' +import { resolveAccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting' import { useDialogService } from '@/services/dialogService' import { useExtensionService } from '@/services/extensionService' import { useLitegraphService } from '@/services/litegraphService' @@ -770,20 +772,13 @@ export class ComfyApp { }) api.addEventListener('execution_error', ({ detail }) => { - // Check if this is an auth-related error or credits-related error - if ( - detail.exception_message?.includes( - 'Unauthorized: Please login first to use this node.' - ) - ) { - useDialogService().showApiNodesSignInDialog([detail.node_type]) - } else if ( - detail.exception_message?.includes( - 'Payment Required: Please add credits to your account to use this node.' - ) - ) { - useDialogService().showTopUpCreditsDialog({ - isInsufficientCredits: true + const precondition = resolveAccountPrecondition({ + exceptionType: detail.exception_type ?? '', + exceptionMessage: detail.exception_message ?? '' + }) + if (precondition) { + useAccountPreconditionDialog().open(precondition, { + nodeType: detail.node_type }) } else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) { useExecutionErrorStore().showErrorOverlay() @@ -1677,6 +1672,24 @@ export class ComfyApp { this.canvas.draw(true, true) } } catch (error: unknown) { + const preconditionResponseError = + error instanceof PromptExecutionError && + typeof error.response.error === 'object' + ? error.response.error + : undefined + const promptPrecondition = preconditionResponseError + ? resolveAccountPrecondition({ + exceptionType: preconditionResponseError.type, + exceptionMessage: preconditionResponseError.message + }) + : undefined + // Account preconditions (sign-in, subscription, credits) open their + // own modal and must stay out of the error panel and error count. + if (promptPrecondition) { + useAccountPreconditionDialog().open(promptPrecondition) + console.error(error) + break + } if ( error instanceof PromptExecutionError && typeof error.response.error === 'object' && diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts index 8df4484fcf6..e306fabdcb5 100644 --- a/src/stores/executionStore.test.ts +++ b/src/stores/executionStore.test.ts @@ -1299,6 +1299,75 @@ describe('useExecutionStore - WebSocket event handlers', () => { exception_message: 'CUDA OOM' }) }) + + it('keeps a subscription precondition (no node_id) out of the error panel and count', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: null, + exception_type: 'InactiveSubscriptionError', + exception_message: + 'User has no active subscription. Please subscribe to a plan to continue.', + traceback: [] + }) + + expect(errorStore.lastExecutionError).toBeNull() + expect(errorStore.lastPromptError).toBeNull() + expect(errorStore.lastNodeErrors).toBeNull() + expect(errorStore.totalErrorCount).toBe(0) + }) + + it('keeps a sign-in precondition out of the error panel and count', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: 'n1', + node_type: 'ApiNode', + exception_type: 'RuntimeError', + exception_message: 'Unauthorized: Please login first to use this node.', + traceback: [] + }) + + expect(errorStore.lastExecutionError).toBeNull() + expect(errorStore.lastPromptError).toBeNull() + expect(errorStore.totalErrorCount).toBe(0) + }) + + it('keeps a runtime credit precondition at a node out of the error panel and count', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: 'n1', + node_type: 'PartnerApiNode', + exception_type: 'InsufficientFundsError', + exception_message: + 'Payment Required: Please add credits to your account to use this node.', + traceback: [] + }) + + expect(errorStore.lastExecutionError).toBeNull() + expect(errorStore.lastPromptError).toBeNull() + expect(errorStore.totalErrorCount).toBe(0) + }) + + it('still routes an ordinary node runtime error to the error panel', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: 'n1', + node_type: 'KSampler', + exception_type: 'RuntimeError', + exception_message: 'Something unrelated broke', + traceback: [] + }) + + expect(errorStore.lastExecutionError).not.toBeNull() + expect(errorStore.totalErrorCount).toBe(1) + }) }) describe('notification', () => { diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index f6ad3bc9c80..216a7689d14 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -9,6 +9,7 @@ import { useAppMode } from '@/composables/useAppMode' import { isCloud } from '@/platform/distribution/types' +import { resolveAccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting' import { useTelemetry } from '@/platform/telemetry' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' @@ -438,6 +439,10 @@ export const useExecutionStore = defineStore('execution', () => { if (handleCloudValidationError(e.detail)) return } + // Account preconditions (sign-in, subscription, credits) open their own + // modal and must stay out of the error panel and error count. + if (handleAccountPreconditionError(e.detail)) return + // Service-level errors (e.g. "Job has stagnated") have no associated node. // Route them as job errors if (handleServiceLevelError(e.detail)) return @@ -448,6 +453,20 @@ export const useExecutionStore = defineStore('execution', () => { resetExecutionState(e.detail.prompt_id) } + function handleAccountPreconditionError( + detail: ExecutionErrorWsMessage + ): boolean { + const precondition = resolveAccountPrecondition({ + exceptionType: detail.exception_type ?? '', + exceptionMessage: detail.exception_message ?? '' + }) + if (!precondition) return false + + clearInitializationByJobId(detail.prompt_id) + resetExecutionState(detail.prompt_id) + return true + } + function handleServiceLevelError(detail: ExecutionErrorWsMessage): boolean { const nodeId = detail.node_id if (nodeId !== null && nodeId !== undefined && String(nodeId) !== '')