mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
6 Commits
glary/subs
...
batch-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d92680ce4 | ||
|
|
167a1e6a0c | ||
|
|
e4e1546458 | ||
|
|
c1954028d1 | ||
|
|
5cad2c952b | ||
|
|
e356addeb6 |
23
.github/actions/ashby-pull/action.yaml
vendored
Normal file
23
.github/actions/ashby-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Ashby Pull
|
||||
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
|
||||
required: true
|
||||
job_board_name:
|
||||
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh Ashby snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
|
||||
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -146,6 +149,9 @@ jobs:
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -36,4 +36,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
59
.github/workflows/release-website.yaml
vendored
Normal file
59
.github/workflows/release-website.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-website
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Refresh Ashby snapshot
|
||||
uses: ./.github/actions/ashby-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
delete-branch: true
|
||||
@@ -7,6 +7,15 @@
|
||||
"github": {
|
||||
"enabled": false
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"has": [
|
||||
{ "type": "host", "value": "website-frontend-comfyui.vercel.app" }
|
||||
],
|
||||
"headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
|
||||
@@ -119,7 +119,15 @@
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
|
||||
export type SubscriptionStatusResponse =
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
|
||||
export type BalanceResponse =
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
|
||||
export function createSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse> = {}
|
||||
): SubscriptionStatusResponse {
|
||||
return {
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: null,
|
||||
has_fund: false,
|
||||
renewal_date: null,
|
||||
end_date: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createBalance(
|
||||
overrides: Partial<BalanceResponse> = {}
|
||||
): BalanceResponse {
|
||||
return {
|
||||
amount_micros: 0,
|
||||
prepaid_balance_micros: 0,
|
||||
cloud_credit_balance_micros: 0,
|
||||
pending_charges_micros: 0,
|
||||
effective_balance_micros: 0,
|
||||
currency: 'USD',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBED: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
|
||||
export const FREE_TIER_ACTIVE: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_free_001',
|
||||
subscription_tier: 'FREE',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const CREATOR_ACTIVE: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_creator_001',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const PRO_ACTIVE: SubscriptionStatusResponse = createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_pro_001',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const CANCELLED_SUBSCRIPTION: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_cancelled_001',
|
||||
subscription_tier: 'CREATOR',
|
||||
subscription_duration: 'MONTHLY',
|
||||
end_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
|
||||
export const ZERO_BALANCE: BalanceResponse = createBalance({
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: 0
|
||||
})
|
||||
|
||||
export const FUNDED_BALANCE: BalanceResponse = createBalance({
|
||||
amount_micros: 2_500_000,
|
||||
prepaid_balance_micros: 1_000_000,
|
||||
cloud_credit_balance_micros: 1_500_000,
|
||||
effective_balance_micros: 2_500_000
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import {
|
||||
createBalance,
|
||||
createSubscriptionStatus,
|
||||
UNSUBSCRIBED,
|
||||
ZERO_BALANCE
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
import type {
|
||||
BalanceResponse,
|
||||
SubscriptionStatusResponse
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
|
||||
export interface SubscriptionConfig {
|
||||
status: SubscriptionStatusResponse
|
||||
balance: BalanceResponse
|
||||
}
|
||||
|
||||
function emptyConfig(): SubscriptionConfig {
|
||||
return {
|
||||
status: createSubscriptionStatus(UNSUBSCRIBED),
|
||||
balance: createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscriptionOperator = (
|
||||
config: SubscriptionConfig
|
||||
) => SubscriptionConfig
|
||||
|
||||
export function withSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse>
|
||||
): SubscriptionOperator {
|
||||
return (config) => ({
|
||||
...config,
|
||||
status: { ...config.status, ...overrides }
|
||||
})
|
||||
}
|
||||
|
||||
export function withBalance(
|
||||
overrides: Partial<BalanceResponse>
|
||||
): SubscriptionOperator {
|
||||
return (config) => ({
|
||||
...config,
|
||||
balance: { ...config.balance, ...overrides }
|
||||
})
|
||||
}
|
||||
|
||||
export function withActiveSubscription(
|
||||
tier: NonNullable<SubscriptionStatusResponse['subscription_tier']> = 'CREATOR'
|
||||
): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: tier,
|
||||
renewal_date: '2099-12-31T00:00:00.000Z',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withFreeTier(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withCancelledSubscription(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'CREATOR',
|
||||
end_date: '2099-12-31T00:00:00.000Z'
|
||||
})
|
||||
}
|
||||
|
||||
export function withUnsubscribed(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null,
|
||||
renewal_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withCredits(amountMicros: number): SubscriptionOperator {
|
||||
return withBalance({
|
||||
amount_micros: amountMicros,
|
||||
effective_balance_micros: amountMicros
|
||||
})
|
||||
}
|
||||
|
||||
export class SubscriptionHelper {
|
||||
private statusResponse: SubscriptionStatusResponse
|
||||
private balanceResponse: BalanceResponse
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: SubscriptionConfig = emptyConfig()
|
||||
) {
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.page.addInitScript(() => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
subscription_required: true
|
||||
}
|
||||
})
|
||||
|
||||
// The cloud build calls `/api/features` at boot via `refreshRemoteConfig`,
|
||||
// which overwrites `window.__CONFIG__` wholesale. Mock it to preserve
|
||||
// `subscription_required: true` after that fetch resolves.
|
||||
const featuresPattern = '**/api/features'
|
||||
const featuresHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: { subscription_required: true } })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: featuresPattern,
|
||||
handler: featuresHandler
|
||||
})
|
||||
await this.page.route(featuresPattern, featuresHandler)
|
||||
|
||||
const statusPattern = '**/customers/cloud-subscription-status'
|
||||
const statusHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.statusResponse })
|
||||
}
|
||||
this.routeHandlers.push({ pattern: statusPattern, handler: statusHandler })
|
||||
await this.page.route(statusPattern, statusHandler)
|
||||
|
||||
const balancePattern = '**/customers/balance'
|
||||
const balanceHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.balanceResponse })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: balancePattern,
|
||||
handler: balanceHandler
|
||||
})
|
||||
await this.page.route(balancePattern, balanceHandler)
|
||||
|
||||
const checkoutPattern = '**/customers/cloud-subscription-checkout**'
|
||||
const checkoutHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
json: { checkout_url: 'https://checkout.stripe.com/mock' }
|
||||
})
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: checkoutPattern,
|
||||
handler: checkoutHandler
|
||||
})
|
||||
await this.page.route(checkoutPattern, checkoutHandler)
|
||||
}
|
||||
|
||||
configure(...operators: SubscriptionOperator[]): void {
|
||||
const base: SubscriptionConfig = {
|
||||
status: createSubscriptionStatus(this.statusResponse),
|
||||
balance: createBalance(this.balanceResponse)
|
||||
}
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
base
|
||||
)
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
setStatus(overrides: Partial<SubscriptionStatusResponse>): void {
|
||||
this.statusResponse = {
|
||||
...this.statusResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
setBalance(overrides: Partial<BalanceResponse>): void {
|
||||
this.balanceResponse = {
|
||||
...this.balanceResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed localStorage with a pending checkout attempt.
|
||||
* Required for `visibilitychange` to trigger a subscription re-fetch,
|
||||
* because `recoverPendingSubscriptionCheckout` checks
|
||||
* `hasPendingSubscriptionCheckoutAttempt()` before fetching.
|
||||
*
|
||||
* Call AFTER page navigation (localStorage needs a page context).
|
||||
*/
|
||||
async seedPendingCheckout(
|
||||
tier: string = 'standard',
|
||||
cycle: string = 'monthly'
|
||||
): Promise<void> {
|
||||
const storageKey = PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
await this.page.evaluate(
|
||||
([key, t, c]) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
attempt_id: `test-${Date.now()}`,
|
||||
started_at_ms: Date.now(),
|
||||
tier: t,
|
||||
cycle: c,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
},
|
||||
[storageKey, tier, cycle] as const
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch `visibilitychange` to trigger pending-checkout recovery.
|
||||
* The app listens for this event and re-fetches subscription status
|
||||
* when a pending checkout attempt exists in localStorage.
|
||||
*/
|
||||
async triggerSubscriptionRefetch(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.statusResponse = createSubscriptionStatus(UNSUBSCRIBED)
|
||||
this.balanceResponse = createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubscriptionHelper(
|
||||
page: Page,
|
||||
...operators: SubscriptionOperator[]
|
||||
): SubscriptionHelper {
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new SubscriptionHelper(page, config)
|
||||
}
|
||||
@@ -87,7 +87,6 @@ export const TestIds = {
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
subscribeToRunButton: 'subscribe-to-run-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
@@ -207,14 +206,13 @@ export const TestIds = {
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
},
|
||||
user: {
|
||||
currentUserButton: 'current-user-button',
|
||||
currentUserIndicator: 'current-user-indicator',
|
||||
currentUserPopover: 'current-user-popover'
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list'
|
||||
jobAssetsList: 'job-assets-list',
|
||||
notificationBanner: 'queue-notification-banner'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
|
||||
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
164
browser_tests/tests/queueNotificationBanners.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Mirrors BANNER_DISMISS_DELAY_MS in src/composables/queue/useQueueNotificationBanners.ts.
|
||||
// Duplicated here to avoid pulling production source (and its litegraph
|
||||
// transitive deps) into the Playwright TS loader.
|
||||
const BANNER_DISMISS_DELAY_MS = 4000
|
||||
const BANNER_ASSERT_TIMEOUT_MS = BANNER_DISMISS_DELAY_MS + 2000
|
||||
|
||||
const REQUEST_ID_PRIMARY = 1
|
||||
const REQUEST_ID_SECONDARY = 2
|
||||
const REQUEST_ID_MISMATCH = 999
|
||||
|
||||
let nextRequestId = 1000
|
||||
const newRequestId = () => nextRequestId++
|
||||
|
||||
function bannerLocator(page: Page) {
|
||||
return page.getByTestId(TestIds.queue.notificationBanner)
|
||||
}
|
||||
|
||||
type DispatchOpts = { batchCount?: number; requestId?: number }
|
||||
|
||||
function dispatchPromptQueueing(page: Page, opts: DispatchOpts = {}) {
|
||||
return page.evaluate(
|
||||
([batchCount, requestId]) => {
|
||||
window.app!.api.dispatchCustomEvent('promptQueueing', {
|
||||
batchCount,
|
||||
requestId
|
||||
})
|
||||
},
|
||||
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchPromptQueued(page: Page, opts: DispatchOpts = {}) {
|
||||
return page.evaluate(
|
||||
([batchCount, requestId]) => {
|
||||
window.app!.api.dispatchCustomEvent('promptQueued', {
|
||||
number: 0,
|
||||
batchCount,
|
||||
requestId
|
||||
})
|
||||
},
|
||||
[opts.batchCount ?? 1, opts.requestId ?? newRequestId()]
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Queue notification banners', { tag: ['@ui'] }, () => {
|
||||
test.describe('Queuing lifecycle', () => {
|
||||
test('promptQueueing event shows a queueing banner', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page)
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('queuing')
|
||||
})
|
||||
|
||||
test('promptQueued upgrades a pending banner to queued', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
await expect(banner).toContainText('queued')
|
||||
})
|
||||
|
||||
test('promptQueued with batch count > 1 shows plural text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, { batchCount: 3 })
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('3')
|
||||
await expect(banner).toContainText('jobs added to queue')
|
||||
})
|
||||
|
||||
test('promptQueued with mismatched requestId enqueues a separate queued banner', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueueing(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_MISMATCH
|
||||
})
|
||||
|
||||
// Pending banner is not upgraded — still shows "queuing".
|
||||
await expect(banner).toContainText('queuing')
|
||||
|
||||
// After the pending banner auto-dismisses, the queued banner appears.
|
||||
await expect(banner).toContainText('queued', {
|
||||
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Auto-dismiss', () => {
|
||||
test('Banner auto-dismisses after timeout', async ({ comfyPage }) => {
|
||||
await dispatchPromptQueued(comfyPage.page)
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toBeHidden({ timeout: BANNER_ASSERT_TIMEOUT_MS })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Notification queue (FIFO)', () => {
|
||||
test('Second notification shows after first auto-dismisses', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 2,
|
||||
requestId: REQUEST_ID_SECONDARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toContainText('Job queued')
|
||||
await expect(banner).toContainText('2 jobs added to queue', {
|
||||
timeout: BANNER_ASSERT_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Direct queued event (no pending predecessor)', () => {
|
||||
test('promptQueued without prior queueing shows queued banner directly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await dispatchPromptQueued(comfyPage.page, {
|
||||
batchCount: 1,
|
||||
requestId: REQUEST_ID_PRIMARY
|
||||
})
|
||||
|
||||
const banner = bannerLocator(comfyPage.page)
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('queued')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,262 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createSubscriptionHelper,
|
||||
withActiveSubscription,
|
||||
withFreeTier,
|
||||
withUnsubscribed
|
||||
} from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { SubscriptionHelper } from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
|
||||
async function openUserPopover(page: Page): Promise<Locator> {
|
||||
// Use dispatchEvent instead of click() to bypass Playwright's actionability
|
||||
// check — in the cloud environment a subscription dialog backdrop can be
|
||||
// present during initial page load and would block a standard click.
|
||||
await page.getByTestId(TestIds.user.currentUserButton).dispatchEvent('click')
|
||||
const popover = page.getByTestId(TestIds.user.currentUserPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
return popover
|
||||
}
|
||||
|
||||
async function clickPopoverSubscribe(page: Page): Promise<void> {
|
||||
const popover = await openUserPopover(page)
|
||||
// Use dispatchEvent instead of click() because the click opens the
|
||||
// subscription dialog whose backdrop appears mid-action; Playwright's
|
||||
// actionability re-check would otherwise see the mask intercepting and
|
||||
// retry until timeout. The button is already known-visible from
|
||||
// openUserPopover, so dispatching a synthetic click is safe here.
|
||||
await popover
|
||||
.getByRole('button', { name: /subscribe/i })
|
||||
.first()
|
||||
.dispatchEvent('click')
|
||||
}
|
||||
|
||||
// Closes the auto-opened subscription-required dialog if present.
|
||||
// Polls briefly because the dialog opens asynchronously after the
|
||||
// `isLoggedIn` watcher fires on app boot.
|
||||
async function dismissSubscriptionDialogIfOpen(page: Page): Promise<void> {
|
||||
// Target only the subscription-required dialog by its known aria-labelledby
|
||||
// key — avoids strict-mode violations when multiple GlobalDialog items are
|
||||
// on the stack simultaneously.
|
||||
const dialog = page.locator('[aria-labelledby="subscription-required"]')
|
||||
// Use expect with a short timeout: this is intentionally a "dismiss if open"
|
||||
// helper, so absence of the dialog (TimeoutError) is not a failure — we
|
||||
// discard only the timeout error, not any other unexpected exception.
|
||||
const appeared = await expect(dialog)
|
||||
.toBeVisible({ timeout: 2000 })
|
||||
.then(() => true)
|
||||
.catch((e: Error) => {
|
||||
if (e.message.includes('Timeout')) return false
|
||||
throw e
|
||||
})
|
||||
if (!appeared) return
|
||||
const closeButton = dialog.getByRole('button', { name: /close/i }).first()
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
// Installs subscription mocks AFTER comfyPage.setup() and reloads the page
|
||||
// so `addInitScript` (which sets `window.__CONFIG__.subscription_required`)
|
||||
// applies before module-level reads in `ComfyRunButton/index.ts` evaluate.
|
||||
// Depending on `comfyPage` here forces ordering: comfyPage's auth + setup
|
||||
// runs first, then mocks are installed, then the page reloads with the
|
||||
// mocked config + intercepted endpoints in place.
|
||||
function createSubscriptionTest(
|
||||
...defaultOps: Parameters<typeof createSubscriptionHelper>[1][]
|
||||
) {
|
||||
return comfyPageFixture.extend<{
|
||||
subscriptionHelper: SubscriptionHelper
|
||||
}>({
|
||||
subscriptionHelper: [
|
||||
async ({ comfyPage }, use) => {
|
||||
const helper = createSubscriptionHelper(comfyPage.page, ...defaultOps)
|
||||
await helper.mock()
|
||||
// Disable the cloud-subscription extension so its `requireActive
|
||||
// Subscription` watcher doesn't auto-open the subscription dialog
|
||||
// on app boot. The PrimeVue Dialog mask would otherwise intercept
|
||||
// pointer events on every topbar button these tests interact with.
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Extension.Disabled': ['Comfy.Cloud.Subscription']
|
||||
})
|
||||
await comfyPage.page.reload()
|
||||
// Wait for Firebase auth to resolve: the user button is v-if="isLoggedIn"
|
||||
// and only renders after onAuthStateChanged fires with the mock user from
|
||||
// IndexedDB. waitForAppReady() does not wait for this — Firebase resolves
|
||||
// asynchronously after app boot. Waiting here ensures the button is
|
||||
// present before any test body tries to click it.
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.user.currentUserButton)
|
||||
).toBeVisible()
|
||||
// Defense-in-depth: if the dialog still surfaces (e.g. via a
|
||||
// different code path), dismiss it before the test runs.
|
||||
await dismissSubscriptionDialogIfOpen(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.clearMocks()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribedTest = createSubscriptionTest(withUnsubscribed())
|
||||
const subscribedTest = createSubscriptionTest(withActiveSubscription('CREATOR'))
|
||||
const freeTierTest = createSubscriptionTest(withFreeTier())
|
||||
|
||||
unsubscribedTest.describe(
|
||||
'Subscription buttons — unsubscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun visible when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun click opens subscription dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
.click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun shows short label at narrow viewport',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.setViewportSize({ width: 393, height: 851 })
|
||||
const btn = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.subscribeToRunButton
|
||||
)
|
||||
await expect(btn).toBeVisible()
|
||||
await expect(btn).not.toContainText(/to run/i)
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover shows subscribe when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
await expect(
|
||||
popover.getByRole('button', { name: /subscribe/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover subscribe click opens dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Subscription state transition updates UI after re-fetch',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
|
||||
// Simulate returning from Stripe checkout: seed pending checkout,
|
||||
// mutate mock to return active subscription, trigger re-fetch.
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Cleanup prevents stale subscription state after dialog close',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
// Use the aria-labelledby key to target only the subscription dialog —
|
||||
// avoids strict-mode violations when a second GlobalDialog is stacked.
|
||||
const dialog = comfyPage.page.locator(
|
||||
'[aria-labelledby="subscription-required"]'
|
||||
)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest.describe(
|
||||
'Subscription buttons — subscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
subscribedTest(
|
||||
'SubscribeToRun hidden when subscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest(
|
||||
'Topbar subscribe button hidden for paid tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
freeTierTest.describe(
|
||||
'Subscription buttons — free tier',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
freeTierTest(
|
||||
'Topbar subscribe button visible for free tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -5,6 +5,7 @@
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
data-testid="queue-notification-banner"
|
||||
>
|
||||
<QueueNotificationBanner :notification="currentNotification" />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
class="p-1 hover:bg-transparent"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.currentUser')"
|
||||
data-testid="current-user-button"
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
|
||||
@@ -98,6 +98,21 @@ import type { WidgetTypeMap } from './widgets/widgetMap'
|
||||
|
||||
// #region Types
|
||||
|
||||
/**
|
||||
* Canonical identifier type for a litegraph node.
|
||||
*
|
||||
* This is the single source of truth for node IDs across the litegraph
|
||||
* library and the workflow schema layer. Other modules SHOULD import
|
||||
* `NodeId` from here (or via the workflow schema re-export) rather than
|
||||
* declaring local `string` / `number | string` aliases.
|
||||
*
|
||||
* Note: `src/renderer/core/layout/types.ts` deliberately defines a
|
||||
* narrower `NodeId = string` for the Vue-node renderer, where IDs are
|
||||
* normalized to strings. That is a separate, intentionally-narrower
|
||||
* layered alias — see the JSDoc there.
|
||||
*
|
||||
* Tracking issue for full migration: #11428.
|
||||
*/
|
||||
export type NodeId = number | string
|
||||
|
||||
export type NodeProperty = string | number | boolean | object
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import type { SafeParseReturnType } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const zRendererType = z.enum([
|
||||
'LG',
|
||||
@@ -12,9 +14,20 @@ const zRendererType = z.enum([
|
||||
// GroupNode is hacking node id to be a string, so we need to allow that.
|
||||
// innerNode.id = `${this.node.id}:${i}`
|
||||
// Remove it after GroupNode is redesigned.
|
||||
export const zNodeId = z.union([z.number().int(), z.string()])
|
||||
export const zNodeId = z.union([
|
||||
z.number().int(),
|
||||
z.string()
|
||||
]) satisfies z.ZodType<NodeId>
|
||||
const zNodeInputName = z.string()
|
||||
export type NodeId = z.infer<typeof zNodeId>
|
||||
|
||||
/**
|
||||
* Re-export of the canonical {@link NodeId} type defined in
|
||||
* `src/lib/litegraph/src/LGraphNode.ts`. Kept here for ergonomic imports
|
||||
* alongside `zNodeId` (the runtime validator) and historical call sites.
|
||||
*
|
||||
* See issue #11428 for the consolidation rationale.
|
||||
*/
|
||||
export type { NodeId }
|
||||
|
||||
/**
|
||||
* UUID identifier for a saved workflow.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
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 type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||
|
||||
const mockHandleSubscribeClick = vi.fn()
|
||||
const mockHandleBackToPricing = vi.fn()
|
||||
const mockHandleAddCreditCard = vi.fn()
|
||||
const mockHandleConfirmTransition = vi.fn()
|
||||
const mockHandleResubscribe = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { back: 'Back', close: 'Close' },
|
||||
subscription: {
|
||||
plansForWorkspace: 'Plans for {workspace}',
|
||||
teamWorkspace: 'Team'
|
||||
},
|
||||
credits: {
|
||||
topUp: {
|
||||
insufficientTitle: 'Insufficient Credits',
|
||||
insufficientMessage: 'You have run out of credits.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const PricingTableStub = {
|
||||
name: 'PricingTableWorkspace',
|
||||
template: `<div data-testid="pricing-table">
|
||||
<button data-testid="subscribe-btn" @click="$emit('subscribe', { tierKey: 'standard', billingCycle: 'yearly' })">Subscribe</button>
|
||||
<button data-testid="resubscribe-btn" @click="$emit('resubscribe')">Resubscribe</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const AddPaymentPreviewStub = {
|
||||
name: 'SubscriptionAddPaymentPreviewWorkspace',
|
||||
template: `<div data-testid="add-payment-preview">
|
||||
<button data-testid="add-card-btn" @click="$emit('addCreditCard')">Add Card</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const TransitionPreviewStub = {
|
||||
name: 'SubscriptionTransitionPreviewWorkspace',
|
||||
template: `<div data-testid="transition-preview">
|
||||
<button data-testid="confirm-btn" @click="$emit('confirm')">Confirm</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
|
||||
) {
|
||||
return render(SubscriptionRequiredDialogContentWorkspace, {
|
||||
props: {
|
||||
onClose: props.onClose ?? vi.fn(),
|
||||
...(props.reason ? { reason: props.reason } : {})
|
||||
},
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({ createSpy: vi.fn, stubActions: false }),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
PricingTableWorkspace: PricingTableStub,
|
||||
SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub,
|
||||
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
})
|
||||
|
||||
it('shows pricing table on pricing step', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('pricing-table')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows close button and hides back button on pricing step', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByLabelText('Close')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Back')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose })
|
||||
|
||||
await user.click(screen.getByLabelText('Close'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows back button on preview step', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
expect(screen.getByLabelText('Back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows insufficient credits message when reason is out_of_credits', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
expect(screen.getByText('Insufficient Credits')).toBeInTheDocument()
|
||||
expect(screen.getByText('You have run out of credits.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show insufficient credits message without reason', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Insufficient Credits')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows new subscription preview when transition_type is new_subscription', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('add-payment-preview')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows transition preview when transition_type is upgrade', () => {
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'upgrade' }
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('transition-preview')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires subscribe event to handleSubscribeClick', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-btn'))
|
||||
|
||||
expect(mockHandleSubscribeClick).toHaveBeenCalledWith({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
})
|
||||
|
||||
it('wires resubscribe event to handleResubscribe', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('resubscribe-btn'))
|
||||
|
||||
expect(mockHandleResubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wires back button to handleBackToPricing', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckoutStep.value = 'preview'
|
||||
mockPreviewData.value = { transition_type: 'new_subscription' }
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByLabelText('Back'))
|
||||
|
||||
expect(mockHandleBackToPricing).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times text-xl" />
|
||||
</Button>
|
||||
@@ -94,28 +94,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
const { onClose, reason } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
@@ -125,227 +111,22 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
const checkoutStep = ref<CheckoutStep>('pricing')
|
||||
const isLoadingPreview = ref(false)
|
||||
const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||
const isSubscribing = ref(false)
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
function getApiPlanSlug(
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
const plan = plans.value.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
async function handleSubscribeClick(payload: {
|
||||
tierKey: CheckoutTierKey
|
||||
billingCycle: BillingCycle
|
||||
}) {
|
||||
const { tierKey, billingCycle } = payload
|
||||
|
||||
isLoadingPreview.value = true
|
||||
loadingTier.value = tierKey
|
||||
selectedTierKey.value = tierKey
|
||||
selectedBillingCycle.value = billingCycle
|
||||
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
if (!planSlug) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await previewSubscribe(planSlug)
|
||||
|
||||
if (!response || !response.allowed) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: response?.reason || 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previewData.value = response
|
||||
checkoutStep.value = 'preview'
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load subscription preview'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isLoadingPreview.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
}
|
||||
|
||||
async function handleAddCreditCard() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to subscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmTransition() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to update subscription'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onClose()
|
||||
}
|
||||
const {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
loadingTier,
|
||||
isSubscribing,
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleBackToPricing,
|
||||
handleAddCreditCard,
|
||||
handleConfirmTransition,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||
|
||||
function makeStandardYearly(): Plan {
|
||||
return {
|
||||
slug: 'standard-yearly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'ANNUAL',
|
||||
price_cents: 1600,
|
||||
credits_cents: 4200,
|
||||
max_seats: 1,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 1600,
|
||||
total_credits_cents: 4200
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCreatorMonthly(): Plan {
|
||||
return {
|
||||
slug: 'creator-monthly',
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 3500,
|
||||
credits_cents: 7400,
|
||||
max_seats: 5,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 3500,
|
||||
total_credits_cents: 7400
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function allPlans(): Plan[] {
|
||||
return [makeStandardYearly(), makeCreatorMonthly()]
|
||||
}
|
||||
|
||||
describe('findPlanSlug', () => {
|
||||
it('finds an annual plan by tier key and yearly billing cycle', () => {
|
||||
expect(findPlanSlug(allPlans(), 'standard', 'yearly')).toBe(
|
||||
'standard-yearly'
|
||||
)
|
||||
})
|
||||
|
||||
it('finds a monthly plan by tier key and monthly billing cycle', () => {
|
||||
expect(findPlanSlug(allPlans(), 'creator', 'monthly')).toBe(
|
||||
'creator-monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no plan matches', () => {
|
||||
expect(findPlanSlug(allPlans(), 'standard', 'monthly')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty plans', () => {
|
||||
expect(findPlanSlug([], 'standard', 'yearly')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const {
|
||||
mockSubscribe,
|
||||
mockPreviewSubscribe,
|
||||
mockFetchStatus,
|
||||
mockFetchBalance,
|
||||
mockPlans,
|
||||
mockResubscribe,
|
||||
mockToastAdd
|
||||
} = vi.hoisted(() => ({
|
||||
mockSubscribe: vi.fn(),
|
||||
mockPreviewSubscribe: vi.fn(),
|
||||
mockFetchStatus: vi.fn(),
|
||||
mockFetchBalance: vi.fn(),
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockResubscribe: vi.fn(),
|
||||
mockToastAdd: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscribe: mockSubscribe,
|
||||
previewSubscribe: mockPreviewSubscribe,
|
||||
plans: computed(() => mockPlans.value),
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: mockResubscribe }
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.comfy.org'
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('useSubscriptionCheckout', () => {
|
||||
let emit: ReturnType<typeof vi.fn>
|
||||
|
||||
async function setup() {
|
||||
const { useSubscriptionCheckout } =
|
||||
await import('./useSubscriptionCheckout')
|
||||
return useSubscriptionCheckout(emit as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockPlans.value = allPlans()
|
||||
emit = vi.fn()
|
||||
})
|
||||
|
||||
describe('handleSubscribeClick', () => {
|
||||
it('transitions to preview on successful preview', async () => {
|
||||
const checkout = await setup()
|
||||
const preview = {
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription' as const,
|
||||
effective_at: '2025-01-01',
|
||||
is_immediate: true,
|
||||
cost_today_cents: 1600,
|
||||
cost_next_period_cents: 1600,
|
||||
credits_today_cents: 4200,
|
||||
credits_next_period_cents: 4200,
|
||||
new_plan: makeStandardYearly().seat_summary
|
||||
}
|
||||
mockPreviewSubscribe.mockResolvedValueOnce(preview)
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('preview')
|
||||
expect(checkout.previewData.value).toStrictEqual(preview)
|
||||
})
|
||||
|
||||
it('shows error toast when preview is disallowed', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
reason: 'Not allowed'
|
||||
})
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Not allowed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when plan slug is not found', async () => {
|
||||
const checkout = await setup()
|
||||
mockPlans.value = []
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'This plan is not available'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast on network failure', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'standard',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Network error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves monthly billing cycle to correct plan slug', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
transition_type: 'new_subscription'
|
||||
})
|
||||
|
||||
await checkout.handleSubscribeClick({
|
||||
tierKey: 'creator',
|
||||
billingCycle: 'monthly'
|
||||
})
|
||||
|
||||
expect(mockPreviewSubscribe).toHaveBeenCalledWith('creator-monthly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleBackToPricing', () => {
|
||||
it('resets to pricing step and clears preview data', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.checkoutStep.value = 'preview'
|
||||
checkout.previewData.value = {} as never
|
||||
|
||||
checkout.handleBackToPricing()
|
||||
|
||||
expect(checkout.checkoutStep.value).toBe('pricing')
|
||||
expect(checkout.previewData.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddCreditCard', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'standard-yearly',
|
||||
'https://platform.comfy.org/payment/success',
|
||||
'https://platform.comfy.org/payment/failed'
|
||||
)
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'needs_payment_method',
|
||||
billing_op_id: 'op-2',
|
||||
payment_method_url: 'https://stripe.com/pay'
|
||||
})
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith('https://stripe.com/pay', '_blank')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows error toast on subscribe failure', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('Payment failed'))
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Payment failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleConfirmTransition', () => {
|
||||
it('emits close on subscribed status', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-3'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleConfirmTransition()
|
||||
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('Transition error'))
|
||||
|
||||
await checkout.handleConfirmTransition()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Transition error'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResubscribe', () => {
|
||||
it('emits close on success', async () => {
|
||||
const checkout = await setup()
|
||||
mockResubscribe.mockResolvedValueOnce({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'active'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleResubscribe()
|
||||
|
||||
expect(mockResubscribe).toHaveBeenCalled()
|
||||
expect(emit).toHaveBeenCalledWith('close', true)
|
||||
})
|
||||
|
||||
it('shows error toast on failure', async () => {
|
||||
const checkout = await setup()
|
||||
mockResubscribe.mockRejectedValueOnce(new Error('Resubscribe failed'))
|
||||
|
||||
await checkout.handleResubscribe()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Resubscribe failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
210
src/platform/workspace/composables/useSubscriptionCheckout.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
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'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
export function findPlanSlug(
|
||||
plans: Plan[],
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY'
|
||||
const apiTier = tierKey.toUpperCase()
|
||||
const plan = plans.find(
|
||||
(p) => p.tier === apiTier && p.duration === apiDuration
|
||||
)
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
export function useSubscriptionCheckout(emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
|
||||
const checkoutStep = ref<CheckoutStep>('pricing')
|
||||
const isLoadingPreview = ref(false)
|
||||
const loadingTier = ref<CheckoutTierKey | null>(null)
|
||||
const isSubscribing = ref(false)
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
function getApiPlanSlug(
|
||||
tierKey: CheckoutTierKey,
|
||||
billingCycle: BillingCycle
|
||||
): string | null {
|
||||
return findPlanSlug(plans.value, tierKey, billingCycle)
|
||||
}
|
||||
|
||||
async function handleSubscribeClick(payload: {
|
||||
tierKey: CheckoutTierKey
|
||||
billingCycle: BillingCycle
|
||||
}) {
|
||||
const { tierKey, billingCycle } = payload
|
||||
|
||||
isLoadingPreview.value = true
|
||||
loadingTier.value = tierKey
|
||||
selectedTierKey.value = tierKey
|
||||
selectedBillingCycle.value = billingCycle
|
||||
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
if (!planSlug) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await previewSubscribe(planSlug)
|
||||
|
||||
if (!response || !response.allowed) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: response?.reason || 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previewData.value = response
|
||||
checkoutStep.value = 'preview'
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load subscription preview'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isLoadingPreview.value = false
|
||||
loadingTier.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'subscribed') {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.required.pollingSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} else if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to subscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutStep,
|
||||
isLoadingPreview,
|
||||
loadingTier,
|
||||
isSubscribing,
|
||||
isResubscribing,
|
||||
previewData,
|
||||
selectedTierKey,
|
||||
selectedBillingCycle,
|
||||
isPolling,
|
||||
handleSubscribeClick,
|
||||
handleBackToPricing,
|
||||
handleAddCreditCard: handleSubscription,
|
||||
handleConfirmTransition: handleSubscription,
|
||||
handleResubscribe
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,20 @@ export interface NodeBoundsUpdate {
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-internal node identifier.
|
||||
*
|
||||
* Intentionally narrower than the canonical {@link NodeId} in
|
||||
* `src/lib/litegraph/src/LGraphNode.ts` (which is `number | string`).
|
||||
* The Vue-node renderer normalizes all incoming IDs to strings before
|
||||
* they enter the layout system, so this layered alias documents and
|
||||
* enforces that contract at the renderer boundary.
|
||||
*
|
||||
* If you need a node ID at the litegraph or workflow-schema layer,
|
||||
* import `NodeId` from `@/lib/litegraph/src/LGraphNode` instead.
|
||||
*
|
||||
* See issue #11428 for the layered-types decision.
|
||||
*/
|
||||
export type NodeId = string
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
|
||||
@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
|
||||
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
|
||||
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
|
||||
],
|
||||
properties: {},
|
||||
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'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user