mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 17:17:19 +00:00
Compare commits
11 Commits
DrJKL-patc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a01c5b3b4 | ||
|
|
e3049e7c31 | ||
|
|
87e84e7280 | ||
|
|
67009dcda2 | ||
|
|
026b2c4795 | ||
|
|
d60260ac3c | ||
|
|
0c89f5a3a7 | ||
|
|
f19597ce81 | ||
|
|
988dc71955 | ||
|
|
da55529d23 | ||
|
|
52d430d1b6 |
@@ -83,6 +83,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
await expect(downloadBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/download/windows/nsis/x64'
|
||||
)
|
||||
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
windows: 'https://comfy.org/download/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve the
|
||||
// workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
|
||||
export const TEAM_WORKSPACE: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
|
||||
export const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
|
||||
// Identity must match the CloudAuthHelper mock user so this row counts as
|
||||
// "(You)".
|
||||
export const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const DEFAULT_TEAM_MEMBERS: Member[] = [
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN
|
||||
]
|
||||
|
||||
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
|
||||
// the whole role-management UI.
|
||||
export const TEAM_PRO_PLAN: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import {
|
||||
DEFAULT_TEAM_MEMBERS,
|
||||
TEAM_BILLING_STATUS,
|
||||
TEAM_PRO_PLAN,
|
||||
TEAM_WORKSPACE,
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked workspace + billing endpoints so
|
||||
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
|
||||
* to reach the OSS devtools backend during setup).
|
||||
*
|
||||
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
|
||||
* `patches` records every role-change request for assertion.
|
||||
*/
|
||||
export class CloudWorkspaceMockHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async setup(
|
||||
members: Member[] = DEFAULT_TEAM_MEMBERS
|
||||
): Promise<MemberMockState> {
|
||||
const state = await this.mockBoot(members)
|
||||
await new CloudAuthHelper(this.page).mockAuth()
|
||||
await this.page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
private async mockBoot(members: Member[]): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: members.map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
const { page } = this
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// A non-empty settings payload with TutorialCompleted marks the user as
|
||||
// returning, so the new-user Templates dialog never auto-opens to block the
|
||||
// Settings button. Errors tab off suppresses the model-folder 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// Echo the updated row like the real BE; the store merges only the role
|
||||
// locally, so the response body shape is not load-bearing.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Minimal valid billing shapes so the billing facade resolves while a
|
||||
* subscription dialog mounts. Active personal sub with zero balance.
|
||||
*/
|
||||
export async function mockBilling(page: Page) {
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
interface CloudBootOptions {
|
||||
/** Remote-config payload for `/api/features` (enables the flags under test). */
|
||||
features: RemoteConfig
|
||||
/** Body for `/api/settings` (defaults to `{}`). */
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
|
||||
* working app without falling through to the OSS devtools backend. Specs layer
|
||||
* their own feature- or flow-specific routes on top.
|
||||
*/
|
||||
export async function mockCloudBoot(
|
||||
page: Page,
|
||||
{ features, settings = {} }: CloudBootOptions
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
|
||||
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
|
||||
* original-owner gate matches against the members self-row.
|
||||
*/
|
||||
export async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
|
||||
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
|
||||
* drift against the real API shape.
|
||||
*/
|
||||
export function jsonRoute<T>(body: T) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
export function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
*/
|
||||
export async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
|
||||
* selects its backend by flag: `team_workspaces_enabled: false` routes through
|
||||
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
|
||||
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
|
||||
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// The workspace `/api/billing/status` shape mirrors the legacy subscription
|
||||
// status; map the fields so a single test fixture drives both backends.
|
||||
const toWorkspaceStatus = (
|
||||
s: CloudSubscriptionStatusResponse
|
||||
): BillingStatusResponse => ({
|
||||
is_active: s.is_active ?? false,
|
||||
subscription_tier: s.subscription_tier ?? undefined,
|
||||
subscription_duration: s.subscription_duration ?? undefined,
|
||||
renewal_date: s.renewal_date ?? undefined,
|
||||
cancel_at: s.end_date ?? undefined,
|
||||
has_funds: s.has_fund ?? true
|
||||
})
|
||||
|
||||
const mockBalance: BillingBalanceResponse = {
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
}
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay would otherwise intercept clicks on the topbar.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy backend (team_workspaces_enabled: false).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
|
||||
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
|
||||
// now routes through `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Boots with team workspaces enabled (production shape); the facade routes a
|
||||
// personal workspace through the workspace `/api/billing/*` endpoints. With
|
||||
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
|
||||
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
|
||||
// overwrites window.__CONFIG__ from /api/features, so the flags must come
|
||||
// from the features mock, not an init script.)
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByTestId('subscribe-to-run-button').click()
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
const BOOT_FEATURES = {
|
||||
onboarding_survey_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// With team workspaces enabled, the facade routes a personal workspace through
|
||||
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
|
||||
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
|
||||
// visible (free tier would swap it for "Upgrade to add credits").
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
has_funds: true,
|
||||
cancel_at: FUTURE_DATE,
|
||||
renewal_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
|
||||
})
|
||||
)
|
||||
|
||||
// Flag-on (team workspaces enabled) routes a personal workspace through the
|
||||
// workspace billing endpoints, so the popover sources its data from here.
|
||||
await page.route('**/api/billing/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBillingStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/plans', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ plans: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data. With team workspaces
|
||||
* enabled the facade routes a personal workspace through the workspace
|
||||
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
|
||||
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
|
||||
* should then render its total / progress bar / monthly+additional breakdown /
|
||||
* add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
|
||||
// same response shape, so one body fulfills both endpoints.
|
||||
const balanceRoute = (balance: {
|
||||
amount: number
|
||||
monthly: number
|
||||
prepaid: number
|
||||
}) =>
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
|
||||
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
|
||||
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
|
||||
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
has_funds: true
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Non-empty settings with a completed tutorial keep the cloud app from
|
||||
// booting as a new user, whose Workflow Templates dialog would otherwise
|
||||
// auto-open and intercept the Settings click behind its modal backdrop.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (flag-off path, api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
|
||||
// Workspace billing (flag-on path) — a personal workspace now routes through
|
||||
// `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(mockBillingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.unroute('**/api/billing/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
CREATOR,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN,
|
||||
VIEWER
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
async function openMembersTab(page: Page): Promise<Locator> {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
function menuButton(row: Locator): Locator {
|
||||
return row.getByRole('button', { name: 'More Options' })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JOHN.email))
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JANE.email))
|
||||
).toBeVisible()
|
||||
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
|
||||
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting the current role is a no-op', async ({ page }) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// The current role is a checked radio item so assistive tech can announce
|
||||
// which role is active.
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JOHN.email,
|
||||
MEMBER_JANE.email
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JANE.email,
|
||||
MEMBER_JOHN.email
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// The promoted owner keeps its row menu (still demotable).
|
||||
await expect(menuButton(janeRow)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demoting an owner returns them to member', async ({ page }) => {
|
||||
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup([
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
ownerJane,
|
||||
MEMBER_JOHN
|
||||
])
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'member'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
member,
|
||||
mockWorkspace,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
|
||||
|
||||
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
|
||||
// chain must not throw before it: a missing settings subpath, prompt exec_info,
|
||||
// or queue status each abort that chain.
|
||||
async function mockGraphBootExtras(page: Page) {
|
||||
// Boot only reads these; fall back on any write so an unexpected POST/PUT
|
||||
// surfaces instead of being masked by a blanket 200.
|
||||
await page.route('**/api/settings/**', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({}))
|
||||
})
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
})
|
||||
await page.route('**/api/queue', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setupCloudApp(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await mockGraphBootExtras(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, ws, members)
|
||||
await bootCloud(page)
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,8 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
--color-coral-700: #b33a3a;
|
||||
@@ -236,6 +238,8 @@
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--tertiary-background: var(--color-smoke-400);
|
||||
--tertiary-background-hover: var(--color-smoke-500);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
--primary-background-hover: var(--color-cobalt-800);
|
||||
@@ -384,6 +388,8 @@
|
||||
--secondary-background: var(--color-charcoal-600);
|
||||
--secondary-background-hover: var(--color-charcoal-400);
|
||||
--secondary-background-selected: var(--color-charcoal-200);
|
||||
--tertiary-background: var(--color-charcoal-400);
|
||||
--tertiary-background-hover: var(--color-charcoal-300);
|
||||
--base-background: var(--color-charcoal-800);
|
||||
--primary-background: var(--color-azure-600);
|
||||
--primary-background-hover: var(--color-azure-400);
|
||||
@@ -554,6 +560,8 @@
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-secondary-background-hover: var(--secondary-background-hover);
|
||||
--color-secondary-background-selected: var(--secondary-background-selected);
|
||||
--color-tertiary-background: var(--tertiary-background);
|
||||
--color-tertiary-background-hover: var(--tertiary-background-hover);
|
||||
--color-primary-background: var(--primary-background);
|
||||
--color-primary-background-hover: var(--primary-background-hover);
|
||||
--color-destructive-background: var(--destructive-background);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
v-tooltip="
|
||||
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
itemClass,
|
||||
String(item.class ?? ''),
|
||||
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
v-bind="
|
||||
'checked' in item
|
||||
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
|
||||
: {}
|
||||
"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu() {
|
||||
return render(DropdownMenu, {
|
||||
props: { entries: [{ label: 'Item A' }] },
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
let openModal: HTMLElement | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (openModal) {
|
||||
ZIndex.clear(openModal)
|
||||
openModal = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('DropdownMenu z-index', () => {
|
||||
it('opens above a dialog registered with the modal z-index counter', async () => {
|
||||
openModal = document.createElement('div')
|
||||
ZIndex.set('modal', openModal, 1700)
|
||||
const dialogZ = Number(openModal.style.zIndex)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
|
||||
})
|
||||
|
||||
it('leaves the static z-index untouched when no dialog is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(menu.style.zIndex).toBe('')
|
||||
expect(menu.className).toContain('z-1700')
|
||||
})
|
||||
})
|
||||
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="credits-container flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<CreditsTile />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
|
||||
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
void usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
v-if="balanceLoading"
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium">{{ data.title }}</div>
|
||||
<div class="text-xs text-muted">{{ data.timestamp }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" :header="$t('g.amount')">
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ data.isPositive ? '+' : '-' }}${{
|
||||
formatMetronomeCurrency(data.amount, 'usd')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
timestamp: string
|
||||
amount: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
@@ -195,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -457,10 +454,7 @@ useEventListener(
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -569,23 +563,8 @@ onMounted(async () => {
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
|
||||
await runUrlActionLoaders()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
|
||||
@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
@@ -233,12 +223,6 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
focusNode: mockFocusNode
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface ErrorCardData {
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -130,7 +130,6 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h, ref } from 'vue'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock the settings dialog composable
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettingsDialog,
|
||||
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
@@ -50,7 +30,6 @@ afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useAuthActions composable
|
||||
const mockLogout = vi.fn()
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the authStore with hoisted state for per-test manipulation
|
||||
const mockAuthStoreState = vi.hoisted(() => ({
|
||||
balance: {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
currency: 'usd'
|
||||
} as {
|
||||
amount_micros?: number
|
||||
effective_balance_micros?: number
|
||||
currency: string
|
||||
},
|
||||
isFetchingBalance: false
|
||||
}))
|
||||
function makeSubscription(
|
||||
overrides: Partial<SubscriptionInfo> = {}
|
||||
): SubscriptionInfo {
|
||||
return {
|
||||
isActive: true,
|
||||
tier: 'CREATOR',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
getAuthHeader: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
|
||||
balance: mockAuthStoreState.balance,
|
||||
isFetchingBalance: mockAuthStoreState.isFetchingBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
|
||||
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
|
||||
const mockBalance = ref<BalanceInfo | null>(null)
|
||||
const mockIsLoading = ref(false)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
fetchStatus: mockFetchStatus
|
||||
tier: mockTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: mockIsLoading,
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscriptionDialog composable
|
||||
const mockShowPricingTable = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
@@ -127,7 +96,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock formatCreditsFromCents
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
// Mock useExternalLink
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
|
||||
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock useTelemetry
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud with hoisted state for per-test toggling
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
default: defineComponent({
|
||||
name: 'SubscribeButtonMock',
|
||||
render() {
|
||||
return h('div', 'Subscribe Button')
|
||||
emits: ['subscribed'],
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'subscribe-button-mock',
|
||||
onClick: () => emit('subscribed')
|
||||
},
|
||||
'Subscribe Button'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
mockTier.value = 'CREATOR'
|
||||
mockSubscription.value = makeSubscription()
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
mockAuthStoreState.isFetchingBalance = false
|
||||
mockIsLoading.value = false
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
|
||||
it('fetches the balance through the billing facade on mount', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes subscription status through the billing facade after subscribing', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('subscribe-button-mock'))
|
||||
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('subscription tier badge', () => {
|
||||
it('renders the tier name derived from the facade tier', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the yearly tier name when the facade subscription is annual', () => {
|
||||
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the badge when the facade reports no tier', () => {
|
||||
mockTier.value = null
|
||||
mockSubscription.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('formats and displays the facade balance', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(formatCreditsFromCents).toHaveBeenCalledWith({
|
||||
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a skeleton instead of the balance while billing is loading', () => {
|
||||
mockIsLoading.value = true
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('1000')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders logout menu item with correct text', () => {
|
||||
renderComponent()
|
||||
|
||||
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('effective_balance_micros handling', () => {
|
||||
it('uses effective_balance_micros when present (positive balance)', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 200_000,
|
||||
effective_balance_micros: 150_000,
|
||||
describe('facade balance handling', () => {
|
||||
it('uses effectiveBalanceMicros when present (positive balance)', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 200_000,
|
||||
effectiveBalanceMicros: 150_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when zero', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 0,
|
||||
it('uses effectiveBalanceMicros when zero', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
effectiveBalanceMicros: 0,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses effective_balance_micros when negative', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: -50_000,
|
||||
it('uses effectiveBalanceMicros when negative', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 0,
|
||||
effectiveBalanceMicros: -50_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('-500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to amount_micros when effective_balance_micros is missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
|
||||
mockBalance.value = {
|
||||
amountMicros: 100_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
|
||||
mockAuthStoreState.balance = {
|
||||
currency: 'usd'
|
||||
}
|
||||
it('falls back to 0 when the facade reports no balance', () => {
|
||||
mockBalance.value = null
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
renderComponent()
|
||||
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('subscribe-button-mock')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows partner nodes menu item', () => {
|
||||
|
||||
@@ -32,12 +32,7 @@
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
width="4rem"
|
||||
height="1.25rem"
|
||||
class="w-full"
|
||||
/>
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
@@ -162,16 +157,15 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useAuthActions()
|
||||
const authStore = useAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
fetchStatus
|
||||
} = useSubscription()
|
||||
tier,
|
||||
subscription,
|
||||
balance,
|
||||
isLoading,
|
||||
fetchStatus,
|
||||
fetchBalance
|
||||
} = useBillingContext()
|
||||
const { formatTierName } = useWorkspaceTierLabel()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const subscriptionTierName = computed(() =>
|
||||
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
|
||||
)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
const cents =
|
||||
authStore.balance?.effective_balance_micros ??
|
||||
authStore.balance?.amount_micros ??
|
||||
0
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
const currentTier = tier.value
|
||||
return (
|
||||
tier === 'FREE' ||
|
||||
tier === 'FOUNDERS_EDITION' ||
|
||||
tier === 'STANDARD' ||
|
||||
tier === 'CREATOR'
|
||||
currentTier === 'FREE' ||
|
||||
currentTier === 'FOUNDERS_EDITION' ||
|
||||
currentTier === 'STANDARD' ||
|
||||
currentTier === 'CREATOR'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const buttonVariants = cva({
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
},
|
||||
@@ -54,6 +56,7 @@ const variants = [
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
TEAM_PLAN_CREDIT_STOPS,
|
||||
getStopDiscountedMonthlyUsd
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
getStopDiscountedMonthlyUsd(current.value, cycle)
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
/** ISO 8601; format at the display site. */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -43,16 +45,27 @@ export interface BillingActions {
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options?: SubscribeOptions
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
/**
|
||||
* Reactivates a cancelled-but-still-active subscription. Legacy has no
|
||||
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
|
||||
* The workspace adapter refreshes status and balance internally on success.
|
||||
*/
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
/**
|
||||
* Purchases additional credits. Standardized on **whole-dollar cents**
|
||||
* (multiples of 100); the legacy adapter divides by 100 for the
|
||||
* dollar-based /customers/credit endpoint.
|
||||
* Pass-through by design: the caller owns the completed/pending follow-up
|
||||
* (balance refresh or billing-op polling), so this does not refresh.
|
||||
*/
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
@@ -80,8 +93,11 @@ export interface BillingState {
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/** Reflects the active workspace's tier, not the user's personal tier. */
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
/** Coarse funding state (`billing_status`); legacy reports null. */
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
@@ -20,12 +22,14 @@ const {
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
mockUpdateActiveWorkspace,
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockUpdateActiveWorkspace: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
|
||||
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
|
||||
get: () => teamWorkspacesEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
}))
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
},
|
||||
updateActiveWorkspace: vi.fn()
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
it('selects legacy type when team workspaces are disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await expect(topup(550)).rejects.toThrow()
|
||||
await expect(topup(0)).rejects.toThrow()
|
||||
await expect(topup(-100)).rejects.toThrow()
|
||||
await expect(topup(99.5)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type, initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: true,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||
const { getMaxSeats } = useBillingContext()
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
if (!sub) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
function resetBillingState() {
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
async function subscribe(planSlug: string, options?: SubscribeOptions) {
|
||||
return activeContext.value.subscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
async function previewSubscribe(
|
||||
planSlug: string,
|
||||
options?: PreviewSubscribeOptions
|
||||
) {
|
||||
return activeContext.value.previewSubscribe(planSlug, options)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
if (
|
||||
!Number.isInteger(amountCents) ||
|
||||
amountCents <= 0 ||
|
||||
amountCents % 100 !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Top-up amount must be a positive whole-dollar cent value'
|
||||
)
|
||||
}
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeOptions,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeOptions,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
_options?: SubscribeOptions
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
_planSlug: string,
|
||||
_options?: PreviewSubscribeOptions
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -49,23 +48,7 @@ export function useFocusNode() {
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
focusNode
|
||||
}
|
||||
}
|
||||
|
||||
96
src/composables/useUrlActionLoaders.test.ts
Normal file
96
src/composables/useUrlActionLoaders.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUrlActionLoaders } from './useUrlActionLoaders'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags.value })
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadInvite: vi.fn().mockResolvedValue(undefined),
|
||||
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
|
||||
loadPricingTable: vi.fn().mockResolvedValue(undefined),
|
||||
useInvite: vi.fn(),
|
||||
useCreateWorkspace: vi.fn(),
|
||||
usePricingTable: vi.fn()
|
||||
}))
|
||||
mocks.useInvite.mockImplementation(() => ({
|
||||
loadInviteFromUrl: mocks.loadInvite
|
||||
}))
|
||||
mocks.useCreateWorkspace.mockImplementation(() => ({
|
||||
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
|
||||
}))
|
||||
mocks.usePricingTable.mockImplementation(() => ({
|
||||
loadPricingTableFromUrl: mocks.loadPricingTable
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
|
||||
useInviteUrlLoader: mocks.useInvite
|
||||
}))
|
||||
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
|
||||
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
|
||||
}))
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
|
||||
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
|
||||
)
|
||||
|
||||
describe('useUrlActionLoaders', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockFlags.value = { teamWorkspacesEnabled: true }
|
||||
})
|
||||
|
||||
it('does not instantiate or run any loader off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.useInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.usePricingTable).not.toHaveBeenCalled()
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs all loaders on cloud when team workspaces are enabled', async () => {
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
|
||||
mockFlags.value = { teamWorkspacesEnabled: false }
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await runUrlActionLoaders()
|
||||
|
||||
expect(mocks.loadInvite).not.toHaveBeenCalled()
|
||||
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
|
||||
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.loadInvite).toHaveBeenCalledOnce()
|
||||
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
55
src/composables/useUrlActionLoaders.ts
Normal file
55
src/composables/useUrlActionLoaders.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
/**
|
||||
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
|
||||
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
|
||||
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
|
||||
* from `onMounted` once the app is ready.
|
||||
*/
|
||||
export function useUrlActionLoaders() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud
|
||||
? useCreateWorkspaceUrlLoader()
|
||||
: null
|
||||
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
|
||||
|
||||
async function runUrlActionLoaders() {
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
|
||||
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1).
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
|
||||
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
|
||||
if (pricingTableUrlLoader) {
|
||||
try {
|
||||
await pricingTableUrlLoader.loadPricingTableFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[UrlActionLoaders] Failed to load pricing table from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runUrlActionLoaders }
|
||||
}
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
|
||||
"yearly": "سنوي",
|
||||
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
|
||||
"yearlyDiscount": "خصم 20%",
|
||||
"saveYearly": "وفّر 20%",
|
||||
"yourPlanIncludes": "خطتك تشمل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -2514,6 +2514,7 @@
|
||||
"resubscribe": "Resubscribe",
|
||||
"resubscribeTo": "Resubscribe to {plan}",
|
||||
"resubscribeSuccess": "Subscription reactivated successfully",
|
||||
"subscribeFailed": "Failed to subscribe",
|
||||
"canceledCard": {
|
||||
"title": "Your subscription has been canceled",
|
||||
"description": "You won't be charged again. Your features remain active until {date}."
|
||||
@@ -2553,6 +2554,23 @@
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"remaining": "remaining",
|
||||
"refillsDate": "Refills {date}",
|
||||
"refillsNextCycle": "Refills next cycle",
|
||||
"creditsUsed": "{used} used",
|
||||
"creditsLeftOfTotal": "{remaining} left of {total}",
|
||||
"monthlyUsageProgress": "{used} of {total} monthly credits used",
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
|
||||
"outOfCreditsTitleNoDate": "You're out of credits",
|
||||
"outOfCreditsDescription": "Add more credits to continue generating.",
|
||||
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
@@ -2563,6 +2581,7 @@
|
||||
"billedYearly": "{total} Billed yearly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"saveYearly": "Save 20%",
|
||||
"tierNameYearly": "{name} Yearly",
|
||||
"messageSupport": "Message support",
|
||||
"invoiceHistory": "Invoice history",
|
||||
@@ -2573,7 +2592,6 @@
|
||||
"benefit2": "Up to 1 hour runtime per job on Pro",
|
||||
"benefit3": "Bring your own models (Creator & Pro)"
|
||||
},
|
||||
"yearlyDiscount": "20% DISCOUNT",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Free"
|
||||
@@ -2650,9 +2668,9 @@
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"unavailable": "This team plan is not available right now.",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
"currentPlan": "Current plan"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
@@ -2721,10 +2739,11 @@
|
||||
"preview": {
|
||||
"confirmPayment": "Confirm your payment",
|
||||
"confirmPlanChange": "Confirm your plan change",
|
||||
"startingToday": "Starting today",
|
||||
"startingToday": "Starts today",
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"eachYearCreditsRefill": "Each year credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
@@ -2735,6 +2754,24 @@
|
||||
"proratedCharge": "Prorated charge for {plan}",
|
||||
"totalDueToday": "Total due today",
|
||||
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
|
||||
"confirmUpgradeTitle": "Confirm your upgrade",
|
||||
"confirmUpgradeCta": "Confirm upgrade",
|
||||
"confirmChange": "Confirm change",
|
||||
"confirmChangeTitle": "Review your scheduled change",
|
||||
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
|
||||
"switchesToday": "Switches today",
|
||||
"startsOn": "Starts {date}",
|
||||
"yearlySubscription": "Yearly subscription",
|
||||
"newMonthlySubscription": "New monthly subscription",
|
||||
"creditFromCurrent": "Credit from current {plan}",
|
||||
"currentMonthly": "monthly plan",
|
||||
"commitment": "commitment",
|
||||
"creditsYoullGetToday": "Credits you'll get today",
|
||||
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
|
||||
"afterThat": "After that",
|
||||
"creditsRefillMonthlyTo": "Credits refill monthly to",
|
||||
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
|
||||
"stayOnUntil": "You'll stay on {plan} until {date}.",
|
||||
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
|
||||
"terms": "Terms",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
@@ -2746,8 +2783,12 @@
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"inviteEmailsPlaceholder": "Enter emails separated by commas",
|
||||
"inviteSubtext": "You can also invite people later from Settings",
|
||||
"inviteTitle": "Invite your team",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -2763,7 +2804,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of 50 members",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2773,7 +2814,8 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"membersCount": "{count}/{maxSeats} Members",
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
@@ -2782,26 +2824,30 @@
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"joinDate": "Join date"
|
||||
"role": "Role"
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Copy invite link",
|
||||
"revokeInvite": "Revoke invite",
|
||||
"resendInvite": "Resend invite",
|
||||
"cancelInvite": "Cancel invite",
|
||||
"changeRole": "Change role",
|
||||
"removeMember": "Remove member"
|
||||
},
|
||||
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
|
||||
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
|
||||
"viewPlans": "View plans",
|
||||
"upsellBanner": "To add teammates, upgrade your plan.",
|
||||
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
|
||||
"upgradeToTeam": "Upgrade to Team",
|
||||
"reactivateTeam": "Reactivate Team",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
|
||||
"createNewWorkspace": "create a new one."
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2825,32 +2871,38 @@
|
||||
"success": "Member removed",
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
"success": "Role updated",
|
||||
"error": "Failed to update role"
|
||||
},
|
||||
"revokeInviteDialog": {
|
||||
"title": "Uninvite this person?",
|
||||
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
|
||||
"revoke": "Uninvite"
|
||||
},
|
||||
"inviteUpsellDialog": {
|
||||
"titleNotSubscribed": "A subscription is required to invite members",
|
||||
"titleNotSubscribed": "A Team plan is required to invite members",
|
||||
"titleSingleSeat": "Your current plan supports a single seat",
|
||||
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
|
||||
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
|
||||
"viewPlans": "View Plans",
|
||||
"upgradeToCreator": "Upgrade to Creator"
|
||||
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
|
||||
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
|
||||
"upgradeToTeam": "Upgrade to Team"
|
||||
},
|
||||
"inviteMemberDialog": {
|
||||
"title": "Invite a person to this workspace",
|
||||
"message": "Create a shareable invite link to send to someone",
|
||||
"placeholder": "Enter the person's email",
|
||||
"createLink": "Create link",
|
||||
"linkStep": {
|
||||
"title": "Send this link to the person",
|
||||
"message": "Make sure their account uses this email.",
|
||||
"copyLink": "Copy Link",
|
||||
"done": "Done"
|
||||
},
|
||||
"linkCopied": "Copied",
|
||||
"linkCopyFailed": "Failed to copy link"
|
||||
"title": "Invite members to this workspace",
|
||||
"placeholder": "Enter emails separated by commas",
|
||||
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
|
||||
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
|
||||
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
|
||||
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
@@ -2877,6 +2929,8 @@
|
||||
"title": "Left workspace",
|
||||
"message": "You have left the workspace."
|
||||
},
|
||||
"inviteResent": "Invite resent",
|
||||
"inviteResendFailed": "Failed to resend invite",
|
||||
"failedToUpdateWorkspace": "Failed to update workspace",
|
||||
"failedToCreateWorkspace": "Failed to create workspace",
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
@@ -3787,7 +3841,6 @@
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
"getHelpTooltip": "Report this error and we'll help you resolve it",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
|
||||
"yearly": "Anual",
|
||||
"yearlyCreditsLabel": "Total de créditos anuales",
|
||||
"yearlyDiscount": "20% DESCUENTO",
|
||||
"saveYearly": "Ahorra 20%",
|
||||
"yourPlanIncludes": "Tu plan incluye:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3859,7 +3859,7 @@
|
||||
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
|
||||
"yearly": "سالانه",
|
||||
"yearlyCreditsLabel": "کل اعتبار سالانه",
|
||||
"yearlyDiscount": "٪۲۰ تخفیف",
|
||||
"saveYearly": "٪۲۰ صرفهجویی",
|
||||
"yourPlanIncludes": "طرح شما شامل:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Cet espace de travail n’a pas d’abonnement",
|
||||
"yearly": "Annuel",
|
||||
"yearlyCreditsLabel": "Crédits annuels totaux",
|
||||
"yearlyDiscount": "20% DE RÉDUCTION",
|
||||
"saveYearly": "Économisez 20 %",
|
||||
"yourPlanIncludes": "Votre forfait comprend :"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
|
||||
"yearly": "年額",
|
||||
"yearlyCreditsLabel": "年間合計クレジット",
|
||||
"yearlyDiscount": "20%割引",
|
||||
"saveYearly": "20%お得",
|
||||
"yourPlanIncludes": "ご利用プランに含まれるもの:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "이 워크스페이스는 구독 중이 아닙니다",
|
||||
"yearly": "연간",
|
||||
"yearlyCreditsLabel": "연간 총 크레딧",
|
||||
"yearlyDiscount": "20% 할인",
|
||||
"saveYearly": "20% 절감",
|
||||
"yourPlanIncludes": "귀하의 플랜 포함 사항:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3859,7 +3859,7 @@
|
||||
"workspaceNotSubscribed": "Este espaço de trabalho não possui uma assinatura",
|
||||
"yearly": "Anual",
|
||||
"yearlyCreditsLabel": "Total de créditos anuais",
|
||||
"yearlyDiscount": "20% DE DESCONTO",
|
||||
"saveYearly": "Economize 20%",
|
||||
"yourPlanIncludes": "Seu plano inclui:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Это рабочее пространство не имеет подписки",
|
||||
"yearly": "Ежегодно",
|
||||
"yearlyCreditsLabel": "Годовые кредиты",
|
||||
"yearlyDiscount": "СКИДКА 20%",
|
||||
"saveYearly": "Экономия 20%",
|
||||
"yourPlanIncludes": "Ваш план включает:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "Bu çalışma alanı bir aboneliğe sahip değil",
|
||||
"yearly": "Yıllık",
|
||||
"yearlyCreditsLabel": "Toplam yıllık krediler",
|
||||
"yearlyDiscount": "%20 İNDİRİM",
|
||||
"saveYearly": "%20 tasarruf",
|
||||
"yourPlanIncludes": "Planınız şunları içerir:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3847,7 +3847,7 @@
|
||||
"workspaceNotSubscribed": "此工作區尚未訂閱",
|
||||
"yearly": "每年",
|
||||
"yearlyCreditsLabel": "年度總點數",
|
||||
"yearlyDiscount": "八折優惠",
|
||||
"saveYearly": "節省 20%",
|
||||
"yourPlanIncludes": "您的方案包含:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
@@ -3859,7 +3859,7 @@
|
||||
"workspaceNotSubscribed": "此工作区未订阅",
|
||||
"yearly": "年度",
|
||||
"yearlyCreditsLabel": "总共年度积分",
|
||||
"yearlyDiscount": "20% 减免",
|
||||
"saveYearly": "立省 20%",
|
||||
"yourPlanIncludes": "您的计划包括:"
|
||||
},
|
||||
"tabMenu": {
|
||||
|
||||
377
src/platform/auth/unified/remintRetry.test.ts
Normal file
377
src/platform/auth/unified/remintRetry.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import type { AxiosAdapter } from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
attachUnifiedRemintInterceptor,
|
||||
fetchWithUnifiedRemint
|
||||
} from '@/platform/auth/unified/remintRetry'
|
||||
|
||||
const { mockRemint, flagState } = vi.hoisted(() => ({
|
||||
mockRemint: vi.fn(),
|
||||
flagState: { unifiedCloudAuthEnabled: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({ remintUnifiedOnce: mockRemint })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get unifiedCloudAuthEnabled() {
|
||||
return flagState.unifiedCloudAuthEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// The axios interceptor gates on shouldRemintCloudRequest(), which is a no-op
|
||||
// off-cloud; the unit env is not a cloud build, so force it on.
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
describe('fetchWithUnifiedRemint', () => {
|
||||
const ok = { status: 200 } as Response
|
||||
const unauthorized = { status: 401 } as Response
|
||||
let mockFetch: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockRemint.mockReset()
|
||||
flagState.unifiedCloudAuthEnabled = true
|
||||
mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('re-mints once and retries with the fresh token on a 401 (AC1)', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(ok)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
|
||||
const retryHeaders = new Headers(mockFetch.mock.calls[1][1].headers)
|
||||
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
|
||||
expect(retryHeaders.get('Comfy-User')).toBe('u1')
|
||||
})
|
||||
|
||||
it('surfaces a persistent 401 after exactly one retry (AC2)', async () => {
|
||||
const secondUnauthorized = { status: 401 } as Response
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(unauthorized)
|
||||
.mockResolvedValueOnce(secondUnauthorized)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(secondUnauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-mint or retry when the caller gate is false (AC3)', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer tokenA' } },
|
||||
false
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry a non-401 response', async () => {
|
||||
const serverError = { status: 500 } as Response
|
||||
mockFetch.mockResolvedValueOnce(serverError)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(serverError)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the original 401 when the re-mint yields no token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockResolvedValue(null)
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces the original 401 when the re-mint throws a permanent auth error', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockRejectedValue(new Error('INVALID_FIREBASE_TOKEN'))
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ headers: { Authorization: 'Bearer t' } },
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces the original 401 without re-minting when the body is a non-replayable stream', async () => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const result = await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{
|
||||
method: 'POST',
|
||||
body: new ReadableStream<Uint8Array>(),
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toBe(unauthorized)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
shape: 'object',
|
||||
headers: { Authorization: 'Bearer tokenA', 'Comfy-User': 'u1' }
|
||||
},
|
||||
{
|
||||
shape: 'array of tuples',
|
||||
headers: [
|
||||
['Authorization', 'Bearer tokenA'],
|
||||
['Comfy-User', 'u1']
|
||||
]
|
||||
},
|
||||
{
|
||||
shape: 'Headers',
|
||||
headers: new Headers({
|
||||
Authorization: 'Bearer tokenA',
|
||||
'Comfy-User': 'u1'
|
||||
})
|
||||
}
|
||||
] as { shape: string; headers: HeadersInit }[])(
|
||||
'preserves method/body and replaces Authorization on a POST retry ($shape headers)',
|
||||
async ({ headers }) => {
|
||||
mockFetch.mockResolvedValueOnce(unauthorized).mockResolvedValueOnce(ok)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
const body = JSON.stringify({ amount: 5 })
|
||||
|
||||
await fetchWithUnifiedRemint(
|
||||
'https://cloud/x',
|
||||
{ method: 'POST', body, headers },
|
||||
true
|
||||
)
|
||||
|
||||
const retryInit = mockFetch.mock.calls[1][1]
|
||||
expect(retryInit.method).toBe('POST')
|
||||
expect(retryInit.body).toBe(body)
|
||||
const retryHeaders = new Headers(retryInit.headers)
|
||||
expect(retryHeaders.get('Authorization')).toBe('Bearer tokenB')
|
||||
expect(retryHeaders.get('Comfy-User')).toBe('u1')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('attachUnifiedRemintInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
mockRemint.mockReset()
|
||||
flagState.unifiedCloudAuthEnabled = true
|
||||
})
|
||||
|
||||
// A custom axios adapter is responsible for its own status handling (axios
|
||||
// only applies validateStatus inside its built-in adapters), so reject
|
||||
// non-2xx with a real AxiosError to mirror a live response.
|
||||
function makeAdapter(statuses: number[]): ReturnType<typeof vi.fn> {
|
||||
let call = 0
|
||||
return vi.fn<AxiosAdapter>(async (config) => {
|
||||
const status = statuses[Math.min(call, statuses.length - 1)]
|
||||
call++
|
||||
const response = {
|
||||
data: status === 200 ? { ok: true } : { message: 'unauthorized' },
|
||||
status,
|
||||
statusText: String(status),
|
||||
headers: {},
|
||||
config
|
||||
}
|
||||
if (status >= 200 && status < 300) {
|
||||
return response
|
||||
}
|
||||
throw new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
null,
|
||||
response
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient(statuses: number[]) {
|
||||
const adapter = makeAdapter(statuses)
|
||||
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
|
||||
attachUnifiedRemintInterceptor(client)
|
||||
return { client, adapter }
|
||||
}
|
||||
|
||||
it('re-mints once and retries the request with the fresh token (AC1)', async () => {
|
||||
const { client, adapter } = makeClient([401, 200])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const res = await client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
expect(String(adapter.mock.calls[1][0].headers.Authorization)).toBe(
|
||||
'Bearer tokenB'
|
||||
)
|
||||
})
|
||||
|
||||
it('retries once then surfaces a persistent 401 (AC2)', async () => {
|
||||
const { client, adapter } = makeClient([401, 401])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not re-mint when the flag is OFF (AC3)', async () => {
|
||||
flagState.unifiedCloudAuthEnabled = false
|
||||
const { client, adapter } = makeClient([401])
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-mint a request flagged __skipUnifiedRemint (acceptInvite)', async () => {
|
||||
const { client, adapter } = makeClient([401])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
await expect(
|
||||
client.post('https://cloud/invites/x/accept', null, {
|
||||
headers: { Authorization: 'Bearer firebase' },
|
||||
__skipUnifiedRemint: true
|
||||
})
|
||||
).rejects.toMatchObject({ response: { status: 401 } })
|
||||
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a non-401 error through without re-minting', async () => {
|
||||
const { client } = makeClient([500])
|
||||
|
||||
await expect(
|
||||
client.get('https://cloud/x', { headers: { Authorization: 'Bearer t' } })
|
||||
).rejects.toMatchObject({ response: { status: 500 } })
|
||||
|
||||
expect(mockRemint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the POST body and method on a retry, with the fresh token', async () => {
|
||||
const { client, adapter } = makeClient([401, 200])
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const res = await client.post(
|
||||
'https://cloud/topup',
|
||||
{ amount: 5 },
|
||||
{ headers: { Authorization: 'Bearer tokenA' } }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adapter).toHaveBeenCalledTimes(2)
|
||||
const firstConfig = adapter.mock.calls[0][0]
|
||||
const retryConfig = adapter.mock.calls[1][0]
|
||||
expect(retryConfig.method).toBe('post')
|
||||
expect(retryConfig.data).toBe(firstConfig.data)
|
||||
expect(String(retryConfig.headers.Authorization)).toBe('Bearer tokenB')
|
||||
})
|
||||
|
||||
it('latches per request — a second request still retries once (no shared latch)', async () => {
|
||||
// Per-URL: first call 401, second (the retry) 200. The latch lives on each
|
||||
// request's config, so a second request must retry independently.
|
||||
const callsByUrl = new Map<string, number>()
|
||||
const adapter = vi.fn<AxiosAdapter>(async (config) => {
|
||||
const url = config.url ?? ''
|
||||
const nth = (callsByUrl.get(url) ?? 0) + 1
|
||||
callsByUrl.set(url, nth)
|
||||
const okStatus = nth >= 2
|
||||
const response = {
|
||||
data: okStatus ? { ok: true } : { message: 'unauthorized' },
|
||||
status: okStatus ? 200 : 401,
|
||||
statusText: okStatus ? '200' : '401',
|
||||
headers: {},
|
||||
config
|
||||
}
|
||||
if (okStatus) return response
|
||||
throw new AxiosError(
|
||||
'Request failed with status code 401',
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
null,
|
||||
response
|
||||
)
|
||||
})
|
||||
const client = axios.create({ adapter: adapter as unknown as AxiosAdapter })
|
||||
attachUnifiedRemintInterceptor(client)
|
||||
mockRemint.mockResolvedValue('tokenB')
|
||||
|
||||
const a = await client.get('https://cloud/a', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
const b = await client.get('https://cloud/b', {
|
||||
headers: { Authorization: 'Bearer tokenA' }
|
||||
})
|
||||
|
||||
expect(a.status).toBe(200)
|
||||
expect(b.status).toBe(200)
|
||||
// Each request: initial 401 + one retry = 4 adapter calls, one re-mint each.
|
||||
expect(adapter).toHaveBeenCalledTimes(4)
|
||||
expect(mockRemint).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
131
src/platform/auth/unified/remintRetry.ts
Normal file
131
src/platform/auth/unified/remintRetry.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig
|
||||
} from 'axios'
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
let cachedUnifiedFlags:
|
||||
| { readonly unifiedCloudAuthEnabled: boolean }
|
||||
| undefined
|
||||
|
||||
/**
|
||||
* Single gate for the reactive guard: a cloud build with `unified_cloud_auth`
|
||||
* ON. Memoizes the feature-flag accessor so the hot `fetchApi` path does not
|
||||
* build a fresh reactive proxy per request (the cached getter still reflects
|
||||
* live flag changes), and is reused at every cloud request seam so the gate
|
||||
* cannot be forgotten on a new call site.
|
||||
*/
|
||||
export async function shouldRemintCloudRequest(): Promise<boolean> {
|
||||
if (!isCloud) return false
|
||||
if (!cachedUnifiedFlags) {
|
||||
const { useFeatureFlags } = await import('@/composables/useFeatureFlags')
|
||||
cachedUnifiedFlags = useFeatureFlags().flags
|
||||
}
|
||||
return cachedUnifiedFlags.unifiedCloudAuthEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mints the unified Cloud JWT once from the current Firebase identity and
|
||||
* returns the fresh token, or `null` when there is nothing to retry with: no
|
||||
* active unified session, or the re-mint failed. A permanent auth failure is
|
||||
* surfaced + torn down inside `remintUnifiedOnce` (error toast + session clear,
|
||||
* matching the proactive refresh path); the `catch` here only guards an
|
||||
* unexpected throw (e.g. a chunk-load failure or no active Pinia), which it
|
||||
* logs. Either way `null` makes the caller surface its original 401 unchanged.
|
||||
*/
|
||||
async function tryRemintToken(): Promise<string | null> {
|
||||
try {
|
||||
const { useWorkspaceAuthStore } =
|
||||
await import('@/platform/workspace/stores/workspaceAuthStore')
|
||||
return await useWorkspaceAuthStore().remintUnifiedOnce()
|
||||
} catch (err) {
|
||||
console.warn('Unified re-mint primitive threw unexpectedly:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a `fetch` and, on a `401`, re-mints the unified Cloud JWT once and
|
||||
* retries the request exactly once with the fresh token. A persistent `401`
|
||||
* (or a `null` re-mint) surfaces the original Response unchanged — no retry
|
||||
* loop. Requires a replayable body: a one-shot `ReadableStream` body cannot be
|
||||
* replayed, so such a request surfaces its original `401` without a retry (no
|
||||
* current cloud caller sends one).
|
||||
*
|
||||
* `shouldRetryOn401` is the caller's gate (see {@link shouldRemintCloudRequest}):
|
||||
* flag-OFF traffic returns after a single `fetch` and never enters the re-mint
|
||||
* path, so the legacy cascade stays untouched for instant rollback.
|
||||
*/
|
||||
export async function fetchWithUnifiedRemint(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit,
|
||||
shouldRetryOn401: boolean
|
||||
): Promise<Response> {
|
||||
const response = await fetch(input, init)
|
||||
if (!shouldRetryOn401 || response.status !== 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
if (init.body instanceof ReadableStream) {
|
||||
console.warn(
|
||||
'fetchWithUnifiedRemint: a ReadableStream body is not replayable; surfacing the original 401'
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
const token = await tryRemintToken()
|
||||
if (!token) {
|
||||
return response
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(input, { ...init, headers })
|
||||
}
|
||||
|
||||
function isRetriableUnauthorized(
|
||||
error: unknown
|
||||
): error is AxiosError & { config: InternalAxiosRequestConfig } {
|
||||
if (!axios.isAxiosError(error)) return false
|
||||
const config = error.config
|
||||
if (!config || config.__unifiedRetried || config.__skipUnifiedRemint) {
|
||||
return false
|
||||
}
|
||||
return error.response?.status === 401
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a response interceptor that gives a cloud axios client the same
|
||||
* reactive 401 guard as {@link fetchWithUnifiedRemint}: a single re-mint + a
|
||||
* single retry on `401`, surfacing a persistent `401` unchanged. A strict
|
||||
* no-op while `unified_cloud_auth` is OFF — the original error rejects exactly
|
||||
* as it does today.
|
||||
*/
|
||||
export function attachUnifiedRemintInterceptor(client: AxiosInstance): void {
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: unknown) => {
|
||||
if (
|
||||
!isRetriableUnauthorized(error) ||
|
||||
!(await shouldRemintCloudRequest())
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const token = await tryRemintToken()
|
||||
if (!token) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Clone (don't mutate) the caller's config so the re-minted Bearer never
|
||||
// leaks into a caller-retained reference, matching fetchWithUnifiedRemint.
|
||||
const { config } = error
|
||||
const headers = new AxiosHeaders(config.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return client.request({ ...config, headers, __unifiedRetried: true })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,15 @@ vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({
|
||||
mockPerformSubscriptionCheckout(...args)
|
||||
}))
|
||||
|
||||
const mockPerformTeamSubscriptionCheckout = vi.fn()
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil',
|
||||
() => ({
|
||||
performTeamSubscriptionCheckout: (...args: unknown[]) =>
|
||||
mockPerformTeamSubscriptionCheckout(...args)
|
||||
})
|
||||
)
|
||||
|
||||
const createI18nInstance = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
@@ -73,6 +82,7 @@ const createI18nInstance = () =>
|
||||
},
|
||||
subscription: {
|
||||
subscribeTo: 'Subscribe to {plan}',
|
||||
teamPlan: { name: 'Team Plan' },
|
||||
tiers: {
|
||||
standard: { name: 'Standard' },
|
||||
creator: { name: 'Creator' },
|
||||
@@ -162,4 +172,24 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('checks out the team plan via the workspace path with the chosen stop and cycle', async () => {
|
||||
await mountView({ tier: 'team', stop: 'team_700', cycle: 'yearly' })
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalledWith('/')
|
||||
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
|
||||
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'team_700',
|
||||
'yearly'
|
||||
)
|
||||
// Team never goes through the personal checkout path
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('redirects to home for a team link with no stop', async () => {
|
||||
await mountView({ tier: 'team', cycle: 'yearly' })
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/')
|
||||
expect(mockPerformTeamSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,19 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { performTeamSubscriptionCheckout } from '@/platform/cloud/subscription/utils/teamSubscriptionCheckoutUtil'
|
||||
|
||||
import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
|
||||
|
||||
function isBillingCycle(value: string): value is BillingCycle {
|
||||
return value === 'monthly' || value === 'yearly'
|
||||
}
|
||||
|
||||
// Only paid personal tiers can be checked out via this redirect.
|
||||
function isCheckoutTierKey(value: string): value is TierKey {
|
||||
return ['standard', 'creator', 'pro', 'founder'].includes(value)
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -35,6 +45,12 @@ const tierDisplayName = computed(() => {
|
||||
return names[selectedTierKey.value]
|
||||
})
|
||||
|
||||
const isTeamCheckout = ref(false)
|
||||
|
||||
const planLabel = computed(() =>
|
||||
isTeamCheckout.value ? t('subscription.teamPlan.name') : tierDisplayName.value
|
||||
)
|
||||
|
||||
const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
const rawType = route.query.tier
|
||||
const rawCycle = route.query.cycle
|
||||
@@ -58,21 +74,36 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Only paid tiers can be checked out via redirect
|
||||
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
|
||||
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
|
||||
const billingCycle: BillingCycle = isBillingCycle(cycleParam)
|
||||
? cycleParam
|
||||
: 'monthly'
|
||||
|
||||
// Team is a per-credit plan picked on a slider, so it carries a `stop` (the
|
||||
// chosen credit commitment) instead of a tier and checks out through the
|
||||
// workspace billing endpoint rather than the personal one.
|
||||
if (tierKeyParam === 'team') {
|
||||
const rawStop = route.query.stop
|
||||
const stopId =
|
||||
typeof rawStop === 'string'
|
||||
? rawStop
|
||||
: Array.isArray(rawStop)
|
||||
? rawStop[0]
|
||||
: null
|
||||
if (!stopId) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
isTeamCheckout.value = true
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCheckoutTierKey(tierKeyParam)) {
|
||||
await router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const tierKey = tierKeyParam as TierKey
|
||||
|
||||
selectedTierKey.value = tierKey
|
||||
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
|
||||
cycleParam = 'monthly'
|
||||
}
|
||||
selectedTierKey.value = tierKeyParam
|
||||
|
||||
if (!isInitialized.value) {
|
||||
await initialize()
|
||||
@@ -81,11 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
cycleParam as BillingCycle,
|
||||
false
|
||||
)
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
@@ -105,18 +132,18 @@ onMounted(() => {
|
||||
class="size-16"
|
||||
/>
|
||||
<p
|
||||
v-if="selectedTierKey"
|
||||
v-if="planLabel"
|
||||
class="font-inter text-base/normal font-normal text-base-foreground"
|
||||
>
|
||||
{{
|
||||
t('subscription.subscribeTo', {
|
||||
plan: tierDisplayName
|
||||
plan: planLabel
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ProgressSpinner v-if="selectedTierKey" class="size-8" stroke-width="4" />
|
||||
<ProgressSpinner v-if="planLabel" class="size-8" stroke-width="4" />
|
||||
<Button
|
||||
v-if="selectedTierKey"
|
||||
v-if="planLabel"
|
||||
as="a"
|
||||
href="/"
|
||||
link
|
||||
|
||||
373
src/platform/cloud/subscription/components/CreditsTile.test.ts
Normal file
373
src/platform/cloud/subscription/components/CreditsTile.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import type { CurrentTeamCreditStop } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
type Balance = Pick<
|
||||
BalanceInfo,
|
||||
'amountMicros' | 'cloudCreditBalanceMicros' | 'prepaidBalanceMicros'
|
||||
>
|
||||
type Subscription = Pick<SubscriptionInfo, 'duration' | 'renewalDate'> & {
|
||||
tier: SubscriptionInfo['tier'] | 'TEAM'
|
||||
}
|
||||
type TeamStop = CurrentTeamCreditStop
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
balance: null as Balance | null,
|
||||
subscription: null as Subscription | null,
|
||||
isActiveSubscription: false,
|
||||
isFreeTier: false,
|
||||
currentTeamCreditStop: null as TeamStop | null,
|
||||
isLoading: false,
|
||||
canTopUp: true,
|
||||
fetchBalance: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
showPricingTable: vi.fn(),
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
trackAddApiCreditButtonClicked: vi.fn(),
|
||||
toastErrorHandler: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
<TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) =>
|
||||
async (...args: TArgs): Promise<TReturn | undefined> => {
|
||||
try {
|
||||
return await action(...args)
|
||||
} catch (e) {
|
||||
state.toastErrorHandler(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
isLoading: computed(() => state.isLoading),
|
||||
fetchBalance: state.fetchBalance,
|
||||
fetchStatus: state.fetchStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({ canTopUp: state.canTopUp }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ showPricingTable: state.showPricingTable })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
totalCredits: 'Total credits',
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
refillsNextCycle: 'Refills next cycle',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
monthlyUsageProgress: '{used} of {total} monthly credits used',
|
||||
additionalCreditsInfo: 'About additional credits',
|
||||
additionalCreditsTooltip: 'Credits you add on top of your plan.',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderTile(props: Record<string, unknown> = {}) {
|
||||
return render(CreditsTile, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} },
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" :data-variant="variant" :disabled="loading" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
},
|
||||
Skeleton: { template: '<div role="status" aria-label="Loading"></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function activeProSubscription() {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
// amountMicros are cents; centsToCredits multiplies by 2.11.
|
||||
state.balance = {
|
||||
amountMicros: 500, // -> 1,055 total
|
||||
cloudCreditBalanceMicros: 200, // -> 422 monthly remaining
|
||||
prepaidBalanceMicros: 300 // -> 633 additional
|
||||
}
|
||||
}
|
||||
|
||||
describe('CreditsTile', () => {
|
||||
beforeEach(() => {
|
||||
state.balance = null
|
||||
state.subscription = null
|
||||
state.isActiveSubscription = false
|
||||
state.isFreeTier = false
|
||||
state.currentTeamCreditStop = null
|
||||
state.isLoading = false
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('1,055')
|
||||
expect(container.textContent).toContain('remaining')
|
||||
})
|
||||
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
id: 'team_2500',
|
||||
credits_monthly: 527500,
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
})
|
||||
|
||||
it('uses the per-month nominal grant for an annual personal tier', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Annual billing still grants the monthly nominal (21,100), not 12x.
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).not.toContain('253,200')
|
||||
})
|
||||
|
||||
it('falls back to a dateless refills label when renewal date is missing', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Refills next cycle')
|
||||
expect(container.textContent).not.toContain('Refills Feb')
|
||||
})
|
||||
|
||||
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: 'not-a-date'
|
||||
}
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain("You're out of credits")
|
||||
expect(container.textContent).not.toContain('Credits refill')
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile({ zeroState: true })
|
||||
expect(container.textContent).toContain('0')
|
||||
expect(container.textContent).not.toContain('left of')
|
||||
expect(container.textContent).not.toContain('Additional credits')
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only the balance with no breakdown when there is no active subscription', () => {
|
||||
state.isActiveSubscription = false
|
||||
state.balance = { amountMicros: 500 }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('1,055')
|
||||
expect(container.textContent).not.toContain('left of')
|
||||
expect(container.textContent).not.toContain('Additional credits')
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('flags spending of additional credits once the monthly allowance is depleted', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 300,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
activeProSubscription()
|
||||
renderTile()
|
||||
await userEvent.click(screen.getByText('Add credits'))
|
||||
expect(state.trackAddApiCreditButtonClicked).toHaveBeenCalledOnce()
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers the upgrade path instead of add-credits on the free tier', async () => {
|
||||
activeProSubscription()
|
||||
state.isFreeTier = true
|
||||
renderTile()
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
await userEvent.click(screen.getByText('Upgrade to add credits'))
|
||||
expect(state.showPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the action button when the user lacks the top-up permission', () => {
|
||||
activeProSubscription()
|
||||
state.canTopUp = false
|
||||
renderTile()
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
expect(screen.queryByText('Upgrade to add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes balance and status from the facade on mount and on demand', async () => {
|
||||
activeProSubscription()
|
||||
renderTile()
|
||||
expect(state.fetchBalance).toHaveBeenCalledOnce()
|
||||
expect(state.fetchStatus).toHaveBeenCalledOnce()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Refresh credits' })
|
||||
)
|
||||
expect(state.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces a failure toast when a refresh rejects', async () => {
|
||||
activeProSubscription()
|
||||
const failure = new Error('network down')
|
||||
state.fetchBalance.mockRejectedValueOnce(failure)
|
||||
renderTile()
|
||||
await waitFor(() =>
|
||||
expect(state.toastErrorHandler).toHaveBeenCalledWith(failure)
|
||||
)
|
||||
})
|
||||
})
|
||||
371
src/platform/cloud/subscription/components/CreditsTile.vue
Normal file
371
src/platform/cloud/subscription/components/CreditsTile.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
:aria-label="$t('subscription.refreshCredits')"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4 text-text-secondary" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">
|
||||
{{ refillsLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="usage.used"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="monthlyTotalCredits ?? 0"
|
||||
:aria-valuetext="monthlyUsageLabel"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-credit"
|
||||
:style="{ width: usedBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-sm @max-[300px]:flex-col @max-[300px]:items-start"
|
||||
>
|
||||
<span class="flex items-center gap-1 text-text-primary">
|
||||
{{ $t('subscription.additionalCredits') }}
|
||||
<button
|
||||
v-tooltip="{
|
||||
value: $t('subscription.additionalCreditsTooltip'),
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="flex items-center text-muted"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</button>
|
||||
<span
|
||||
v-if="isSpendingAdditional"
|
||||
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-2xs/none font-semibold text-base-background uppercase"
|
||||
>
|
||||
{{ $t('subscription.additionalCreditsInUse') }}
|
||||
</span>
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.usedAfterMonthly') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showActionButton" class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
class="w-full font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
>
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? ''
|
||||
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
)
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
formatCredits({
|
||||
value,
|
||||
locale: locale.value,
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : formatCreditCount(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
const monthlyUsageLabel = computed(() =>
|
||||
t('subscription.monthlyUsageProgress', {
|
||||
used: usedDisplay.value,
|
||||
total: monthlyTotalDisplay.value
|
||||
})
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
() =>
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
|
||||
: t('subscription.outOfCreditsTitleNoDate'),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
})
|
||||
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
telemetry?.trackAddApiCreditButtonClicked()
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
async function handleWindowFocus() {
|
||||
if (consumePendingTopup()) {
|
||||
await handleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(window, 'focus', () => void handleWindowFocus())
|
||||
|
||||
onMounted(handleRefresh)
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import FreeTierDialogContent from './FreeTierDialogContent.vue'
|
||||
|
||||
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
renewalDate: mockRenewalDate
|
||||
}))
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(FreeTierDialogContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('FreeTierDialogContent', () => {
|
||||
it('renders the next refresh line formatted from the facade renewalDate', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('Your credits refresh on Jul 15, 2026.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the next refresh line when renewalDate is null', () => {
|
||||
mockRenewalDate.value = null
|
||||
renderComponent()
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -102,9 +102,9 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
defineProps<{
|
||||
@@ -116,7 +116,17 @@ defineEmits<{
|
||||
upgrade: []
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
const { renewalDate } = useBillingContext()
|
||||
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!renewalDate.value) return ''
|
||||
|
||||
return new Date(renewalDate.value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -23,10 +24,8 @@ function createDeferredPromise<T>() {
|
||||
}
|
||||
|
||||
const mockIsActiveSubscription = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
>(null)
|
||||
const mockIsYearlySubscription = ref(false)
|
||||
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
|
||||
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
|
||||
const mockAccessBillingPortal = vi.fn()
|
||||
const mockReportError = vi.fn()
|
||||
const mockTrackBeginCheckout = vi.fn()
|
||||
@@ -65,13 +64,25 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
isFreeTier: computed(() => false),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||
subscriptionStatus: ref(null)
|
||||
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
|
||||
tier: computed(() => mockSubscriptionTier.value),
|
||||
subscription: computed(() =>
|
||||
mockSubscriptionTier.value
|
||||
? {
|
||||
isActive: mockIsActiveSubscription.value,
|
||||
tier: mockSubscriptionTier.value,
|
||||
duration: mockSubscriptionDuration.value,
|
||||
planSlug: null,
|
||||
renewalDate: null,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
: null
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -217,7 +228,7 @@ describe('PricingTable', () => {
|
||||
vi.clearAllMocks()
|
||||
mockIsActiveSubscription.value = false
|
||||
mockSubscriptionTier.value = null
|
||||
mockIsYearlySubscription.value = false
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
mockUserId.value = 'user-123'
|
||||
mockAccessBillingPortal.mockReset()
|
||||
mockAccessBillingPortal.mockResolvedValue(true)
|
||||
@@ -362,6 +373,7 @@ describe('PricingTable', () => {
|
||||
it('should not call accessBillingPortal when clicking current plan', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockSubscriptionDuration.value = 'ANNUAL'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
@@ -370,12 +382,29 @@ describe('PricingTable', () => {
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Current Plan'))
|
||||
|
||||
expect(currentPlanButton).toBeDefined()
|
||||
expect(currentPlanButton).toBeDisabled()
|
||||
await userEvent.click(currentPlanButton!)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
const currentPlanButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Current Plan'))
|
||||
|
||||
expect(currentPlanButton).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should initiate checkout instead of billing portal for new subscribers', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
<span>{{ option.label }}</span>
|
||||
<div
|
||||
v-if="option.value === 'yearly'"
|
||||
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
|
||||
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
|
||||
>
|
||||
-20%
|
||||
{{ t('subscription.saveYearly') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,15 +67,15 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span
|
||||
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
|
||||
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
|
||||
>
|
||||
${{ getPrice(tier) }}
|
||||
<span
|
||||
v-show="currentBillingCycle === 'yearly'"
|
||||
class="text-2xl text-muted-foreground line-through"
|
||||
>
|
||||
${{ tier.pricing.monthly }}
|
||||
</span>
|
||||
${{ getPrice(tier) }}
|
||||
</span>
|
||||
<span class="font-inter text-xl/normal text-base-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
@@ -122,9 +122,12 @@
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ n(getCreditsDisplay(tier)) }}
|
||||
</span>
|
||||
@@ -136,7 +139,7 @@
|
||||
{{ t('subscription.maxDurationLabel') }}
|
||||
</span>
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ tier.maxDuration }}
|
||||
</span>
|
||||
@@ -186,7 +189,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
|
||||
>
|
||||
~{{ n(tier.pricing.videoEstimate) }}
|
||||
</span>
|
||||
@@ -263,8 +266,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import {
|
||||
TIER_PRICING,
|
||||
TIER_TO_KEY
|
||||
@@ -361,9 +364,13 @@ const tiers: PricingTierConfig[] = [
|
||||
const {
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
subscriptionTier,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
tier: subscriptionTier,
|
||||
subscription
|
||||
} = useBillingContext()
|
||||
|
||||
const isYearlySubscription = computed(
|
||||
() => subscription.value?.duration === 'ANNUAL'
|
||||
)
|
||||
const telemetry = useTelemetry()
|
||||
const { userId } = storeToRefs(useAuthStore())
|
||||
const { accessBillingPortal, reportError } = useAuthActions()
|
||||
|
||||
@@ -16,7 +16,6 @@ import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -38,8 +37,8 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { subscriptionTier } = useSubscription()
|
||||
const { isActiveSubscription, showSubscriptionDialog, tier } =
|
||||
useBillingContext()
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
watch(
|
||||
@@ -54,7 +53,7 @@ watch(
|
||||
|
||||
const handleSubscribe = () => {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
|
||||
@@ -132,6 +132,19 @@ const i18n = createI18n({
|
||||
partnerNodesCredits: 'Partner nodes pricing',
|
||||
renewsDate: 'Renews {date}',
|
||||
expiresDate: 'Expires {date}',
|
||||
monthlyCreditsLabel: 'monthly credits',
|
||||
maxDurationLabel: 'max run duration',
|
||||
maxDuration: {
|
||||
free: '5 min',
|
||||
standard: '30 min',
|
||||
creator: '30 min',
|
||||
pro: '1 hr',
|
||||
founder: '30 min'
|
||||
},
|
||||
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
|
||||
addCreditsLabel: 'Add more credits whenever',
|
||||
customLoRAsLabel: 'Import your own LoRAs',
|
||||
membersLabel: '{count} members',
|
||||
tiers: {
|
||||
founder: {
|
||||
name: "Founder's Edition",
|
||||
@@ -200,6 +213,7 @@ function createComponent(overrides = {}) {
|
||||
CloudBadge: true,
|
||||
SubscribeButton: true,
|
||||
SubscriptionBenefits: true,
|
||||
CreditsTile: true,
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
|
||||
@@ -240,7 +254,6 @@ describe('SubscriptionPanel', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Manage Subscription')
|
||||
expect(container.textContent).toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows correct UI for inactive subscription', () => {
|
||||
@@ -249,7 +262,6 @@ describe('SubscriptionPanel', () => {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('subscribe-button-stub')).not.toBeNull()
|
||||
expect(container.textContent).not.toContain('Manage Subscription')
|
||||
expect(container.textContent).not.toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows renewal date for active non-cancelled subscription', () => {
|
||||
@@ -266,58 +278,19 @@ describe('SubscriptionPanel', () => {
|
||||
expect(container.textContent).toContain('Expires 2024-12-31')
|
||||
})
|
||||
|
||||
it('displays FOUNDERS_EDITION tier correctly', () => {
|
||||
it('displays FOUNDERS_EDITION tier without the custom-LoRA perk', () => {
|
||||
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain("Founder's Edition")
|
||||
expect(container.textContent).toContain('5,460')
|
||||
expect(container.textContent).toContain('RTX 6000 Pro (96GB VRAM)')
|
||||
expect(container.textContent).not.toContain('Import your own LoRAs')
|
||||
})
|
||||
|
||||
it('displays CREATOR tier correctly', () => {
|
||||
it('displays CREATOR tier with the custom-LoRA perk', () => {
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Creator')
|
||||
expect(container.textContent).toContain('7,400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('credit display functionality', () => {
|
||||
it('displays dynamic credit values correctly', () => {
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('10.00 Credits')
|
||||
expect(container.textContent).toContain('5.00 Credits')
|
||||
})
|
||||
|
||||
it('shows loading skeleton when fetching balance', () => {
|
||||
mockCreditsData.isLoadingBalance = true
|
||||
createComponent()
|
||||
expect(
|
||||
screen.getAllByRole('status', { name: 'Loading' }).length
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hides skeleton when balance loaded', () => {
|
||||
mockCreditsData.isLoadingBalance = false
|
||||
createComponent()
|
||||
expect(screen.queryAllByRole('status', { name: 'Loading' })).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('renders refill date with literal slashes', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('TZ', 'UTC')
|
||||
try {
|
||||
mockIsActiveSubscription.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toMatch(
|
||||
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
|
||||
)
|
||||
expect(container.textContent).not.toContain('/')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
expect(container.textContent).toContain('Import your own LoRAs')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,15 +308,6 @@ describe('SubscriptionPanel', () => {
|
||||
await userEvent.click(supportButton)
|
||||
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should call handleRefresh when refresh button is clicked', async () => {
|
||||
createComponent()
|
||||
const refreshButton = screen.getByRole('button', {
|
||||
name: 'Refresh credits'
|
||||
})
|
||||
await userEvent.click(refreshButton)
|
||||
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
@@ -353,14 +317,5 @@ describe('SubscriptionPanel', () => {
|
||||
const supportButton = findButtonByText('Message Support')
|
||||
expect(supportButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show loading state on refresh button when loading balance', () => {
|
||||
mockCreditsData.isLoadingBalance = true
|
||||
createComponent()
|
||||
const refreshButton = screen.getByRole('button', {
|
||||
name: 'Refresh credits'
|
||||
})
|
||||
expect(refreshButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,100 +65,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 pt-9 lg:flex-row">
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||
'bg-modal-panel-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-4 right-4"
|
||||
:loading="isLoadingBalance"
|
||||
:aria-label="$t('subscription.refreshCredits')"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i class="pi pi-sync text-sm text-text-secondary" />
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-muted">
|
||||
{{ $t('subscription.totalCredits') }}
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="text-2xl font-bold">
|
||||
{{ totalCredits }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Breakdown -->
|
||||
<table class="text-sm text-muted">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||
</td>
|
||||
<td class="align-middle" :title="creditsRemainingLabel">
|
||||
{{ creditsRemainingLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pr-4 text-left align-middle font-bold">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="3rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else>{{ prepaidCredits }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="align-middle"
|
||||
:title="$t('subscription.creditsYouveAdded')"
|
||||
>
|
||||
{{ $t('subscription.creditsYouveAdded') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<a
|
||||
href="https://platform.comfy.org/profile/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-muted underline"
|
||||
>
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && isFreeTier"
|
||||
variant="gradient"
|
||||
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
>
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
variant="secondary"
|
||||
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
|
||||
@click="handleAddApiCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:max-w-md">
|
||||
<CreditsTile />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -207,26 +115,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits,
|
||||
getTierPrice
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const authActions = useAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
@@ -239,12 +144,10 @@ const {
|
||||
formattedEndDate,
|
||||
subscriptionTier,
|
||||
subscriptionTierName,
|
||||
subscriptionStatus,
|
||||
isYearlySubscription
|
||||
} = useSubscription()
|
||||
|
||||
const { show: showSubscriptionDialog, showPricingTable } =
|
||||
useSubscriptionDialog()
|
||||
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
@@ -255,89 +158,11 @@ const tierPrice = computed(() =>
|
||||
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||
)
|
||||
|
||||
const refillsDate = computed(() => {
|
||||
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = String(date.getFullYear()).slice(-2)
|
||||
return `${month}/${day}/${year}`
|
||||
})
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
const credits = getTierCredits(tierKey.value)
|
||||
if (credits === null) return '—'
|
||||
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||
return n(total)
|
||||
})
|
||||
|
||||
const includedCreditsDisplay = computed(
|
||||
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||
)
|
||||
|
||||
const tierBenefits = computed((): TierBenefit[] =>
|
||||
getCommonTierBenefits(tierKey.value, t, n)
|
||||
)
|
||||
|
||||
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||
useSubscriptionCredits()
|
||||
|
||||
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleWindowFocus() {
|
||||
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||
if (!timestampStr) return
|
||||
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
|
||||
// Clear expired tracking (older than 5 minutes)
|
||||
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh and clear tracking to prevent repeated calls
|
||||
void handleRefresh()
|
||||
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
})
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -81,6 +81,42 @@ describe('useBillingPlans', () => {
|
||||
expect(currentPlanSlug.value).toBeNull()
|
||||
})
|
||||
|
||||
it('populates teamCreditStops from the response', async () => {
|
||||
const stops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
monthly: { list_price_cents: 70_000, price_cents: 66_500 },
|
||||
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
|
||||
}
|
||||
]
|
||||
}
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
plans: [buildPlan()],
|
||||
team_credit_stops: stops
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, teamCreditStops } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(teamCreditStops.value).toEqual(stops)
|
||||
})
|
||||
|
||||
it('leaves teamCreditStops null when the response omits it', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({ plans: [buildPlan()] })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, teamCreditStops } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(teamCreditStops.value).toBeNull()
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls while a fetch is in flight', async () => {
|
||||
let resolveFetch: (value: { plans: Plan[] }) => void = () => {}
|
||||
mockGetBillingPlans.mockImplementation(
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePricingTableUrlLoader } from './usePricingTableUrlLoader'
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn(),
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/navigation/preservedQueryManager',
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
const mockRouteQuery = vi.hoisted(() => ({
|
||||
value: {} as Record<string, string>
|
||||
}))
|
||||
const mockRouterReplace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: mockRouteQuery.value
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: mockRouterReplace
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShowPricingTable = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
showPricingTable: mockShowPricingTable
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const mockPermissions = vi.hoisted(() => ({
|
||||
value: { canManageSubscriptionLifecycle: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({ permissions: mockPermissions })
|
||||
}))
|
||||
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn().mockResolvedValue([]))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
fetchMembers: mockFetchMembers
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
describe('usePricingTableUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRouteQuery.value = {}
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: true }
|
||||
// clearAllMocks resets calls, not implementations, so restore the default
|
||||
// (a test overrides fetchMembers to flip the gate mid-await).
|
||||
mockFetchMembers.mockResolvedValue([])
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when no pricing param present', async () => {
|
||||
mockRouteQuery.value = {}
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the pricing table for an original owner', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
reason: 'deep_link'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
it('reads the gate only after members finish loading', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
// The original owner becomes known only once the members list resolves;
|
||||
// proves the loader awaits fetchMembers before reading the gate.
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
mockFetchMembers.mockImplementation(async () => {
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: true }
|
||||
return []
|
||||
})
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens on the team tab for ?pricing=team', async () => {
|
||||
mockRouteQuery.value = { pricing: 'team' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: 'team'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens on the personal tab for ?pricing=personal', async () => {
|
||||
mockRouteQuery.value = { pricing: 'personal' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: 'personal'
|
||||
})
|
||||
})
|
||||
|
||||
it('is a silent no-op for a member or promoted owner', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies, strips, and clears together when the user is not eligible', async () => {
|
||||
mockRouteQuery.value = { pricing: '1', other: 'param' }
|
||||
mockPermissions.value = { canManageSubscriptionLifecycle: false }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { other: 'param' }
|
||||
})
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores preserved query and opens the table', async () => {
|
||||
mockRouteQuery.value = {}
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
|
||||
pricing: '1'
|
||||
})
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
expect(mockShowPricingTable).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('strips but does not open for an empty param', async () => {
|
||||
mockRouteQuery.value = { pricing: '' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips but does not open for a non-string param', async () => {
|
||||
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
it('opens the default tab for an unrecognized pricing value', async () => {
|
||||
mockRouteQuery.value = { pricing: 'garbage' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).toHaveBeenCalledWith({
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('strips and clears, then propagates a members-fetch failure', async () => {
|
||||
mockRouteQuery.value = { pricing: '1' }
|
||||
mockFetchMembers.mockRejectedValue(new Error('listMembers failed'))
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await expect(loadPricingTableFromUrl()).rejects.toThrow(
|
||||
'listMembers failed'
|
||||
)
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING
|
||||
|
||||
/**
|
||||
* Opens the pricing table from a `?pricing=` deep link, to send pilot users
|
||||
* straight to subscribe. Values: `1` (default tab), `team`, `personal`.
|
||||
*
|
||||
* Gated to the original owner (`canManageSubscriptionLifecycle`); a member or
|
||||
* promoted owner is a silent no-op with the param stripped. Survives the login
|
||||
* redirect via the preserved-query system, like the invite URL loader.
|
||||
*/
|
||||
export function usePricingTableUrlLoader() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
|
||||
async function loadPricingTableFromUrl() {
|
||||
hydratePreservedQuery(NAMESPACE)
|
||||
const query =
|
||||
mergePreservedQueryIntoQuery(NAMESPACE, route.query) ?? route.query
|
||||
const param = query.pricing
|
||||
if (param === undefined) return
|
||||
|
||||
// Strip any present pricing param (even ineligible or malformed values) and
|
||||
// write the clean URL in a single replace before any await, so a clean URL
|
||||
// is guaranteed even if the replace rejects or the gate later denies.
|
||||
const cleanQuery = { ...query }
|
||||
delete cleanQuery.pricing
|
||||
router.replace({ query: cleanQuery }).catch((error) => {
|
||||
console.warn(
|
||||
'[usePricingTableUrlLoader] Failed to clean URL params:',
|
||||
error
|
||||
)
|
||||
})
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
|
||||
// Only a non-empty string value opens the table; an empty/array param just
|
||||
// gets stripped above.
|
||||
if (typeof param !== 'string' || !param) return
|
||||
|
||||
// Load members before reading the gate so the original-owner self-row is
|
||||
// present. fetchMembers always awaits the request; ensureMembersLoaded can
|
||||
// early-return on a cached/in-flight load and let the gate read empty members.
|
||||
await workspaceStore.fetchMembers()
|
||||
if (!permissions.value.canManageSubscriptionLifecycle) return
|
||||
|
||||
const planMode =
|
||||
param === 'team' || param === 'personal' ? param : undefined
|
||||
|
||||
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
|
||||
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
|
||||
}
|
||||
|
||||
return {
|
||||
loadPricingTableFromUrl
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -54,6 +56,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { getAuthHeader } = authStore
|
||||
const { flags } = useFeatureFlags()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
@@ -247,7 +250,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
/**
|
||||
* Whether cloud subscription mode is enabled (cloud distribution with subscription_required config).
|
||||
* Use to determine which UI to show (SubscriptionPanel vs LegacyCreditsPanel).
|
||||
* Use to determine which UI to show (SubscriptionPanel vs CreditsPanel).
|
||||
*/
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
@@ -324,11 +327,12 @@ function useSubscriptionInternal() {
|
||||
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
|
||||
const headers = await buildAuthHeaders()
|
||||
|
||||
const response = await fetch(
|
||||
const response = await fetchWithUnifiedRemint(
|
||||
buildApiUrl('/customers/cloud-subscription-status'),
|
||||
{
|
||||
headers
|
||||
}
|
||||
},
|
||||
isCloud && flags.unifiedCloudAuthEnabled
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -414,13 +418,14 @@ function useSubscriptionInternal() {
|
||||
const headers = await buildAuthHeaders()
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
const response = await fetchWithUnifiedRemint(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
},
|
||||
isCloud && flags.unifiedCloudAuthEnabled
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -2,26 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
|
||||
// Mock dependencies
|
||||
const mockFetchBalance = vi.fn()
|
||||
const mockBillingFetchBalance = vi.fn()
|
||||
const mockAuthFetchBalance = vi.fn()
|
||||
const mockFetchStatus = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
const mockExecute = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
fetchStatus: mockFetchStatus
|
||||
fetchBalance: mockAuthFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockBillingFetchBalance,
|
||||
fetchStatus: mockFetchStatus
|
||||
})
|
||||
}))
|
||||
@@ -119,20 +119,21 @@ describe('useSubscriptionActions', () => {
|
||||
})
|
||||
|
||||
describe('handleRefresh', () => {
|
||||
it('should call both fetchBalance and fetchStatus', async () => {
|
||||
it('should refresh balance and status through the billing facade', async () => {
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
await handleRefresh()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalledOnce()
|
||||
expect(mockBillingFetchBalance).toHaveBeenCalledOnce()
|
||||
expect(mockFetchStatus).toHaveBeenCalledOnce()
|
||||
expect(mockAuthFetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
|
||||
it('swallows refresh failures without surfacing a toast', async () => {
|
||||
mockBillingFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
|
||||
const { handleRefresh } = useSubscriptionActions()
|
||||
|
||||
// Should not throw
|
||||
await expect(handleRefresh()).resolves.toBeUndefined()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -11,10 +10,9 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
*/
|
||||
export function useSubscriptionActions() {
|
||||
const dialogService = useDialogService()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { fetchStatus } = useBillingContext()
|
||||
const { fetchBalance, fetchStatus } = useBillingContext()
|
||||
|
||||
const isLoadingSupport = ref(false)
|
||||
|
||||
@@ -44,7 +42,7 @@ export function useSubscriptionActions() {
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await Promise.all([authActions.fetchBalance(), fetchStatus()])
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
} catch (error) {
|
||||
console.error('[useSubscriptionActions] Error refreshing data:', error)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,28 @@ describe('useSubscriptionCredits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('numeric credit values (micros-as-cents)', () => {
|
||||
it('converts the monthly and prepaid balance fields from cents to credits (×2.11)', () => {
|
||||
mockBillingBalance = {
|
||||
amountMicros: 500,
|
||||
cloudCreditBalanceMicros: 200,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
|
||||
useSubscriptionCredits()
|
||||
expect(monthlyBonusCreditsValue.value).toBe(422)
|
||||
expect(prepaidCreditsValue.value).toBe(633)
|
||||
})
|
||||
|
||||
it('defaults missing fields to zero', () => {
|
||||
mockBillingBalance = { amountMicros: 100 }
|
||||
const { monthlyBonusCreditsValue, prepaidCreditsValue } =
|
||||
useSubscriptionCredits()
|
||||
expect(monthlyBonusCreditsValue.value).toBe(0)
|
||||
expect(prepaidCreditsValue.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoadingBalance', () => {
|
||||
it('should reflect billingContext.isLoading', () => {
|
||||
mockBillingIsLoading = true
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
centsToCredits,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
/**
|
||||
@@ -50,10 +53,23 @@ export function useSubscriptionCredits() {
|
||||
|
||||
const isLoadingBalance = computed(() => toValue(billingContext.isLoading))
|
||||
|
||||
const creditsFromMicros = (maybeCents: number | undefined): number =>
|
||||
centsToCredits(maybeCents ?? 0)
|
||||
|
||||
const monthlyBonusCreditsValue = computed(() =>
|
||||
creditsFromMicros(toValue(billingContext.balance)?.cloudCreditBalanceMicros)
|
||||
)
|
||||
|
||||
const prepaidCreditsValue = computed(() =>
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isFreeTier: mockIsFreeTier
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
@@ -65,6 +59,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isFreeTier: mockIsFreeTier,
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan
|
||||
})
|
||||
}))
|
||||
@@ -115,10 +110,8 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
|
||||
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
|
||||
// a personal-plan or team-plan workspace.
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -207,6 +200,43 @@ describe('useSubscriptionDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
it('opens the free-tier dialog for a free-tier personal user', () => {
|
||||
mockIsFreeTier.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'free-tier-info' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the pricing table for a non-free-tier user', () => {
|
||||
mockIsFreeTier.value = false
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the pricing table for a free-tier team workspace', () => {
|
||||
mockIsFreeTier.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
it('closes existing dialogs before opening team workspace dialog', () => {
|
||||
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
@@ -16,6 +15,7 @@ export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
@@ -33,7 +33,6 @@ export const useSubscriptionDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { isFreeTier } = useSubscription()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
@@ -159,6 +158,10 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
|
||||
function show(options?: SubscriptionDialogOptions) {
|
||||
// Free-tier state comes from the unified facade so it works on both the
|
||||
// legacy (/customers) and workspace (/api/billing) paths. Resolved lazily
|
||||
// (not at composable setup) to avoid the useBillingContext import cycle.
|
||||
const { isFreeTier } = useBillingContext()
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getStopDiscountedMonthlyUsd,
|
||||
getTeamPlanSlug,
|
||||
mapApiTeamCreditStops
|
||||
} from './teamPlanCreditStops'
|
||||
|
||||
describe('mapApiTeamCreditStops', () => {
|
||||
it('derives usd, credits, discount and carries the backend id', () => {
|
||||
const mapped = mapApiTeamCreditStops([
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { list_price_cents: 70_000, price_cents: 63_000 }
|
||||
}
|
||||
])
|
||||
|
||||
expect(mapped).toEqual([
|
||||
{
|
||||
id: 'team_700',
|
||||
usd: 700,
|
||||
credits: 147_700,
|
||||
discountPercentYearly: 10
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a 0% discount when the list price is zero', () => {
|
||||
const mapped = mapApiTeamCreditStops([
|
||||
{
|
||||
id: 'team_free',
|
||||
credits: 0,
|
||||
yearly: { list_price_cents: 0, price_cents: 0 }
|
||||
}
|
||||
])
|
||||
|
||||
expect(mapped[0].discountPercentYearly).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStopDiscountedMonthlyUsd', () => {
|
||||
it('applies the full yearly discount for the yearly cycle', () => {
|
||||
expect(
|
||||
getStopDiscountedMonthlyUsd(
|
||||
{ usd: 700, discountPercentYearly: 10 },
|
||||
'yearly'
|
||||
)
|
||||
).toBe(630)
|
||||
})
|
||||
|
||||
it('halves the discount for the monthly cycle', () => {
|
||||
expect(
|
||||
getStopDiscountedMonthlyUsd(
|
||||
{ usd: 700, discountPercentYearly: 10 },
|
||||
'monthly'
|
||||
)
|
||||
).toBe(665)
|
||||
})
|
||||
|
||||
it('reads the stop discount so backend-driven stops are honored', () => {
|
||||
expect(
|
||||
getStopDiscountedMonthlyUsd(
|
||||
{ usd: 1000, discountPercentYearly: 25 },
|
||||
'yearly'
|
||||
)
|
||||
).toBe(750)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTeamPlanSlug', () => {
|
||||
it('maps the billing cycle to the per-credit team plan slug', () => {
|
||||
expect(getTeamPlanSlug('monthly')).toBe('team_per_credit_monthly')
|
||||
expect(getTeamPlanSlug('yearly')).toBe('team_per_credit_annual')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
export interface CreditStop {
|
||||
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
|
||||
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
|
||||
id?: string
|
||||
/** Monthly subscription price in USD (pre-discount). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
@@ -17,6 +20,9 @@ export interface CreditStop {
|
||||
|
||||
/** A selected slider stop, as emitted by the pricing table's team column. */
|
||||
export interface TeamPlanSelection {
|
||||
/** Backend stop identifier (e.g. "team_700"), sent on subscribe. Present for
|
||||
* API-sourced stops; absent only for the hardcoded OSS / pre-deploy fallback. */
|
||||
id?: string
|
||||
/** Pre-discount monthly price in USD (the struck-through list price). */
|
||||
usd: number
|
||||
/** Monthly credit grant at this stop. */
|
||||
@@ -26,17 +32,14 @@ export interface TeamPlanSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-plan credit-subscription slider stops.
|
||||
* Team-plan credit-subscription slider stops — OSS / pre-deploy fallback.
|
||||
*
|
||||
* Hardcoded per Figma DES-197 (Updates to PricingTable dialog): the team-plan
|
||||
* credit slider snaps to exactly these 5 fixed breakpoints — the user cannot
|
||||
* select a value in between. The `credits` figures equal `usdToCredits(usd)` at
|
||||
* the current rate (`CREDITS_PER_USD = 211`); a unit test guards against rate
|
||||
* drift silently changing the designed values.
|
||||
*
|
||||
* TODO(FE-934): once the backend slider contract lands, these stops (and their
|
||||
* discount tiers) will come from `GET /api/billing/plans` instead of being
|
||||
* hardcoded here.
|
||||
* The live set comes from `GET /api/billing/plans → team_credit_stops` (mapped
|
||||
* via `mapApiTeamCreditStops`); these hardcoded DES-197 breakpoints render only
|
||||
* when the API doesn't supply them. The slider snaps to exactly these 5 fixed
|
||||
* breakpoints — the user cannot select a value in between. The `credits` figures
|
||||
* equal `usdToCredits(usd)` at the current rate (`CREDITS_PER_USD = 211`); a unit
|
||||
* test guards against rate drift silently changing the designed values.
|
||||
*/
|
||||
export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
{ usd: 200, credits: 42_200, discountPercentYearly: 0 },
|
||||
@@ -50,20 +53,58 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
|
||||
|
||||
/**
|
||||
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
|
||||
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
|
||||
* the slider display and the checkout confirm step so the two never drift.
|
||||
* Falls back to the list price when `usd` is not a known stop.
|
||||
* Per-credit Team plan slug for a billing cadence (cloud catalog). The slug
|
||||
* encodes the cadence; `POST /api/billing/subscribe` reads `plan_slug` +
|
||||
* `team_credit_stop_id` and resolves all amounts server-side from the stop.
|
||||
*/
|
||||
export function getDiscountedMonthlyUsd(
|
||||
usd: number,
|
||||
export function getTeamPlanSlug(billingCycle: 'monthly' | 'yearly'): string {
|
||||
return billingCycle === 'yearly'
|
||||
? 'team_per_credit_annual'
|
||||
: 'team_per_credit_monthly'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the backend `team_credit_stops` payload to the slider's `CreditStop[]`.
|
||||
* The pre-discount monthly `usd` is the yearly list price; the yearly discount
|
||||
* percent is derived from the struck (`list_price_cents`) vs discounted
|
||||
* (`price_cents`) yearly figures. The backend `id` is carried so a selected stop
|
||||
* can be sent on subscribe.
|
||||
*/
|
||||
export function mapApiTeamCreditStops(
|
||||
stops: readonly {
|
||||
id: string
|
||||
credits: number
|
||||
yearly: { list_price_cents: number; price_cents: number }
|
||||
}[]
|
||||
): CreditStop[] {
|
||||
return stops.map((stop) => {
|
||||
const listCents = stop.yearly.list_price_cents
|
||||
const discountPercentYearly =
|
||||
listCents > 0
|
||||
? Math.round(((listCents - stop.yearly.price_cents) / listCents) * 100)
|
||||
: 0
|
||||
return {
|
||||
id: stop.id,
|
||||
usd: Math.round(listCents / 100),
|
||||
credits: stop.credits,
|
||||
discountPercentYearly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Discounted monthly price for a credit stop, applying the billing-cycle
|
||||
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
|
||||
* the slider display and the checkout confirm step so the two never drift, and
|
||||
* it reads the stop's own discount so backend-driven stops are honored.
|
||||
*/
|
||||
export function getStopDiscountedMonthlyUsd(
|
||||
stop: Pick<CreditStop, 'usd' | 'discountPercentYearly'>,
|
||||
cycle: 'monthly' | 'yearly'
|
||||
): number {
|
||||
const stop = TEAM_PLAN_CREDIT_STOPS.find((s) => s.usd === usd)
|
||||
if (!stop) return usd
|
||||
const percent =
|
||||
cycle === 'monthly'
|
||||
? stop.discountPercentYearly / 2
|
||||
: stop.discountPercentYearly
|
||||
return Math.round(usd * (1 - percent / 100))
|
||||
return Math.round(stop.usd * (1 - percent / 100))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
describe('computeMonthlyUsage', () => {
|
||||
it('reports the consumed portion of the monthly allowance', () => {
|
||||
expect(computeMonthlyUsage(105_450, 200_000)).toEqual({
|
||||
used: 94_550,
|
||||
usedFraction: 0.47275
|
||||
})
|
||||
})
|
||||
|
||||
it('returns zero usage when the monthly allowance is unknown', () => {
|
||||
expect(computeMonthlyUsage(100, 0)).toEqual({ used: 0, usedFraction: 0 })
|
||||
})
|
||||
|
||||
it('treats a balance above the allowance (rollover) as nothing used', () => {
|
||||
expect(computeMonthlyUsage(503_805, 253_200)).toEqual({
|
||||
used: 0,
|
||||
usedFraction: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the fill at a full bar once the allowance is exhausted', () => {
|
||||
expect(computeMonthlyUsage(0, 200_000)).toEqual({
|
||||
used: 200_000,
|
||||
usedFraction: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('caps used at the allowance when the remaining balance is negative', () => {
|
||||
expect(computeMonthlyUsage(-50_000, 200_000)).toEqual({
|
||||
used: 200_000,
|
||||
usedFraction: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
28
src/platform/cloud/subscription/utils/creditsProgress.ts
Normal file
28
src/platform/cloud/subscription/utils/creditsProgress.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface MonthlyCreditsUsage {
|
||||
/** Credits consumed from the monthly allowance (never negative). */
|
||||
used: number
|
||||
/** Fraction (0–1) of the monthly allowance consumed — drives the bar fill. */
|
||||
usedFraction: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes monthly credit usage for the credits bar. The bar fills with the
|
||||
* consumed portion of the monthly allowance; `used` clamps at zero so a balance
|
||||
* that exceeds the nominal allowance (rolled-over credits) reads as nothing used.
|
||||
*/
|
||||
export function computeMonthlyUsage(
|
||||
monthlyRemaining: number,
|
||||
monthlyTotal: number
|
||||
): MonthlyCreditsUsage {
|
||||
if (monthlyTotal <= 0) {
|
||||
return { used: 0, usedFraction: 0 }
|
||||
}
|
||||
|
||||
const used = Math.min(
|
||||
monthlyTotal,
|
||||
Math.max(0, monthlyTotal - monthlyRemaining)
|
||||
)
|
||||
const usedFraction = Math.min(1, used / monthlyTotal)
|
||||
|
||||
return { used, usedFraction }
|
||||
}
|
||||
21
src/platform/cloud/subscription/utils/planDuration.ts
Normal file
21
src/platform/cloud/subscription/utils/planDuration.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SubscriptionDuration } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
/** Backend plan duration `'ANNUAL'` maps to the FE's yearly billing cycle. */
|
||||
export const isAnnualDuration = (
|
||||
duration: SubscriptionDuration | undefined
|
||||
): boolean => duration === 'ANNUAL'
|
||||
|
||||
/**
|
||||
* Whether a checkout step renders as yearly. The preview's resolved plan
|
||||
* duration wins; absent a preview (fresh subscribe with no proration) it falls
|
||||
* back to the user's selected billing cycle.
|
||||
*/
|
||||
export const isYearlyCheckout = (
|
||||
planDuration: SubscriptionDuration | undefined,
|
||||
billingCycle: BillingCycle
|
||||
): boolean =>
|
||||
planDuration !== undefined
|
||||
? isAnnualDuration(planDuration)
|
||||
: billingCycle === 'yearly'
|
||||
@@ -1,7 +1,9 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
@@ -70,13 +72,14 @@ export async function performSubscriptionCheckout(
|
||||
}
|
||||
const checkoutPayload = { ...checkoutAttribution }
|
||||
|
||||
const response = await fetch(
|
||||
const response = await fetchWithUnifiedRemint(
|
||||
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(checkoutPayload)
|
||||
}
|
||||
},
|
||||
isCloud && useFeatureFlags().flags.unifiedCloudAuthEnabled
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://app.test'
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { subscribe: mockSubscribe }
|
||||
}))
|
||||
|
||||
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
|
||||
|
||||
describe('performTeamSubscriptionCheckout', () => {
|
||||
let assignedHref: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
assignedHref = undefined
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
set href(value: string) {
|
||||
assignedHref = value
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('subscribes at the stop with the yearly slug and redirects to the Stripe payment page', async () => {
|
||||
mockSubscribe.mockResolvedValue({
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://stripe.test/pay',
|
||||
billing_op_id: 'op_1'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
|
||||
returnUrl: 'https://app.test/payment/success',
|
||||
cancelUrl: 'https://app.test/payment/failed',
|
||||
teamCreditStopId: 'team_700'
|
||||
})
|
||||
expect(assignedHref).toBe('https://stripe.test/pay')
|
||||
})
|
||||
|
||||
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
|
||||
mockSubscribe.mockResolvedValue({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op_2'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_1400', 'monthly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_monthly', {
|
||||
returnUrl: expect.any(String),
|
||||
cancelUrl: expect.any(String),
|
||||
teamCreditStopId: 'team_1400'
|
||||
})
|
||||
expect(assignedHref).toBe('/')
|
||||
})
|
||||
|
||||
it('throws when payment is needed but no payment URL is returned', async () => {
|
||||
mockSubscribe.mockResolvedValue({
|
||||
status: 'needs_payment_method',
|
||||
billing_op_id: 'op_3'
|
||||
})
|
||||
|
||||
await expect(
|
||||
performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
).rejects.toThrow(/payment URL/)
|
||||
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
/**
|
||||
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
|
||||
* link: subscribes to the per-credit Team plan at the chosen slider stop and
|
||||
* sends the user straight to the Stripe payment page.
|
||||
*
|
||||
* Mirrors `performSubscriptionCheckout` (personal) but routes through the
|
||||
* workspace billing endpoint (`POST /api/billing/subscribe`), because the
|
||||
* per-credit Team plan lives there and the backend lets any workspace — personal
|
||||
* included — subscribe to it. The slug encodes the cadence; the stop id is
|
||||
* validated and priced server-side.
|
||||
*
|
||||
* Caller guards on `isCloud`, owns loading state, and wraps error handling. A
|
||||
* `needs_payment_method` response is a full-page redirect to Stripe; the other
|
||||
* statuses land back in the app, which polls the billing op to completion.
|
||||
*/
|
||||
export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId: string,
|
||||
billingCycle: BillingCycle
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const planSlug = getTeamPlanSlug(billingCycle)
|
||||
const response = await workspaceApi.subscribe(planSlug, {
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`,
|
||||
teamCreditStopId
|
||||
})
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
// A needs_payment_method response without a URL is unusable: surface it to
|
||||
// the caller's error handling rather than silently dropping the user home
|
||||
// with a subscription stuck mid-payment.
|
||||
if (!response.payment_method_url) {
|
||||
throw new Error(
|
||||
'Team subscription needs a payment method but no payment URL was returned'
|
||||
)
|
||||
}
|
||||
globalThis.location.href = response.payment_method_url
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.location.href = '/'
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
SHARE: 'share',
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth'
|
||||
OAUTH: 'oauth',
|
||||
PRICING: 'pricing'
|
||||
} as const
|
||||
|
||||
@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
@@ -130,7 +130,7 @@ const {
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authActions = useAuthActions()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
@@ -238,7 +238,7 @@ watch(activeCategoryKey, (newKey, oldKey) => {
|
||||
activeCategoryKey.value = oldKey
|
||||
}
|
||||
if (newKey === 'credits') {
|
||||
void authActions.fetchBalance()
|
||||
void fetchBalance()
|
||||
}
|
||||
if (newKey) {
|
||||
void nextTick(() => {
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useSettingUI(
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import type {
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -112,6 +113,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkspaceInviteSent?.(metadata))
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(properties))
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -182,6 +183,12 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
|
||||
// GA4 names must be bare snake_case; the TelemetryEvents enum carries an
|
||||
// `app:` prefix for Mixpanel/PostHog that dataLayer would forward verbatim.
|
||||
this.pushEvent('workspace_invite_sent', metadata)
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.pushEvent('run_workflow', {
|
||||
subscribe_to_run: properties.subscribe_to_run,
|
||||
|
||||
@@ -39,7 +39,8 @@ import type {
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
@@ -258,6 +259,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
|
||||
}
|
||||
|
||||
// Credit top-up tracking methods (composition with utility functions)
|
||||
startTopupTracking(): void {
|
||||
startTopupUtil()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueModule from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -12,6 +15,10 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
const refs = {
|
||||
tier: null as unknown as Ref<string | null>,
|
||||
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
return {
|
||||
mockCapture,
|
||||
@@ -23,6 +30,7 @@ const hoisted = vi.hoisted(() => {
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
refs,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
@@ -36,14 +44,6 @@ const hoisted = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved,
|
||||
@@ -51,21 +51,19 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRemoteConfig = vi.hoisted(
|
||||
() => ({ value: null }) as { value: Record<string, unknown> | null }
|
||||
)
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: mockRemoteConfig
|
||||
}))
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', async () => {
|
||||
const { ref } = await vi.importActual<typeof VueModule>('vue')
|
||||
hoisted.refs.remoteConfig = ref<Record<string, unknown> | null>(null)
|
||||
return { remoteConfig: hoisted.refs.remoteConfig }
|
||||
})
|
||||
|
||||
vi.mock('posthog-js', () => hoisted.mockPosthog)
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
subscriptionTier: { value: null }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingContext', async () => {
|
||||
const { ref } = await vi.importActual<typeof VueModule>('vue')
|
||||
hoisted.refs.tier = ref<string | null>(null)
|
||||
return { useBillingContext: () => ({ tier: hoisted.refs.tier }) }
|
||||
})
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
@@ -82,7 +80,10 @@ function createProvider(
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRemoteConfig.value = null
|
||||
hoisted.refs.remoteConfig.value = null
|
||||
// Fresh tier ref per test: each provider registers an undisposed tier
|
||||
// watch, so a shared ref would leak watchers across tests.
|
||||
hoisted.refs.tier = ref<string | null>(null)
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -116,7 +117,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
it('applies posthog_config overrides from remote config', async () => {
|
||||
mockRemoteConfig.value = {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: {
|
||||
debug: true,
|
||||
api_host: 'https://custom.host.com'
|
||||
@@ -150,6 +151,48 @@ describe('PostHogTelemetryProvider', () => {
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
|
||||
})
|
||||
|
||||
function tierPropertySets(): unknown[] {
|
||||
return hoisted.mockPeopleSet.mock.calls
|
||||
.map(([props]) => props)
|
||||
.filter((props) => props && 'subscription_tier' in props)
|
||||
}
|
||||
|
||||
it('sets subscription_tier reactively when the facade tier resolves', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
onResolved({ id: 'user-123' })
|
||||
|
||||
// Unresolved tier (null) does not set the property
|
||||
expect(tierPropertySets()).toHaveLength(0)
|
||||
|
||||
hoisted.refs.tier.value = 'PRO'
|
||||
await nextTick()
|
||||
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
|
||||
subscription_tier: 'PRO'
|
||||
})
|
||||
|
||||
hoisted.refs.tier.value = null
|
||||
await nextTick()
|
||||
expect(tierPropertySets()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a single tier watcher across repeated user resolutions', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const onResolved = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
onResolved({ id: 'user-1' })
|
||||
onResolved({ id: 'user-1' })
|
||||
onResolved({ id: 'user-2' })
|
||||
|
||||
hoisted.refs.tier.value = 'PRO'
|
||||
await nextTick()
|
||||
|
||||
expect(tierPropertySets()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
@@ -670,7 +713,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
mockRemoteConfig.value = {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
@@ -41,7 +42,8 @@ import type {
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowSavedMetadata,
|
||||
WorkspaceInviteMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
@@ -98,6 +100,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isInitialized = false
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
private stopSubscriptionTierWatch: WatchStopHandle | null = null
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
@@ -307,12 +310,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
subscriptionTier,
|
||||
(tier) => {
|
||||
if (tier && this.posthog) {
|
||||
this.posthog.people.set({ subscription_tier: tier })
|
||||
if (this.stopSubscriptionTierWatch) return
|
||||
const { tier } = useBillingContext()
|
||||
this.stopSubscriptionTierWatch = watch(
|
||||
tier,
|
||||
(value) => {
|
||||
if (value && this.posthog) {
|
||||
this.posthog.people.set({ subscription_tier: value })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -370,6 +374,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackWorkspaceInviteSent(metadata: WorkspaceInviteMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKSPACE_INVITE_SENT, metadata)
|
||||
}
|
||||
|
||||
trackRunButton(properties: RunButtonProperties): void {
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
consumePendingTopup,
|
||||
startTopupTracking,
|
||||
checkForCompletedTopup,
|
||||
clearTopupTracking
|
||||
@@ -227,4 +228,35 @@ describe('topupTracker', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('consumePendingTopup', () => {
|
||||
it('returns false and clears nothing when no marker exists', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
expect(consumePendingTopup()).toBe(false)
|
||||
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears and returns true for a fresh marker', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(
|
||||
(Date.now() - 5 * 60 * 1000).toString()
|
||||
)
|
||||
|
||||
expect(consumePendingTopup()).toBe(true)
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears and returns false for a marker older than 24 hours', () => {
|
||||
mockLocalStorage.getItem.mockReturnValue(
|
||||
(Date.now() - 25 * 60 * 60 * 1000).toString()
|
||||
)
|
||||
|
||||
expect(consumePendingTopup()).toBe(false)
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
|
||||
'pending_topup_timestamp'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,3 +61,16 @@ export function checkForCompletedTopup(
|
||||
export function clearTopupTracking(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a pending top-up marker on window focus. Clears the marker and
|
||||
* reports whether a non-expired purchase was awaiting a balance refresh.
|
||||
*/
|
||||
export function consumePendingTopup(): boolean {
|
||||
const timestampStr = localStorage.getItem(STORAGE_KEY)
|
||||
if (!timestampStr) return false
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
const timestamp = parseInt(timestampStr, 10)
|
||||
return Date.now() - timestamp <= MAX_AGE_MS
|
||||
}
|
||||
|
||||
@@ -464,6 +464,11 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
ecommerce: EcommerceMetadata
|
||||
}
|
||||
|
||||
export interface WorkspaceInviteMetadata extends Record<string, unknown> {
|
||||
source: 'post_upgrade_success' | 'settings_members'
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
@@ -487,6 +492,7 @@ export interface TelemetryProvider {
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
|
||||
trackRunButton?(properties: RunButtonProperties): void
|
||||
|
||||
// Credit top-up tracking (composition with internal utilities)
|
||||
@@ -590,6 +596,7 @@ export const TelemetryEvents = {
|
||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||
'app:api_credit_topup_button_purchase_clicked',
|
||||
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
|
||||
WORKSPACE_INVITE_SENT: 'app:workspace_invite_sent',
|
||||
BEGIN_CHECKOUT: 'begin_checkout',
|
||||
|
||||
// Onboarding Survey
|
||||
|
||||
@@ -9,7 +9,8 @@ const {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn()
|
||||
delete: vi.fn(),
|
||||
interceptors: { response: { use: vi.fn() } }
|
||||
},
|
||||
mockGetAuthHeaderOrThrow: vi.fn(),
|
||||
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
|
||||
@@ -211,6 +212,27 @@ describe('workspaceApi', () => {
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
})
|
||||
|
||||
it('updateMemberRole() sends PATCH /workspace/members/:userId with the role', async () => {
|
||||
const updated = {
|
||||
id: 'user-42',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
mockAxiosInstance.patch.mockResolvedValue({ data: updated })
|
||||
|
||||
const result = await workspaceApi.updateMemberRole('user-42', 'owner')
|
||||
|
||||
expect(mockAxiosInstance.patch).toHaveBeenCalledWith(
|
||||
'/api/workspace/members/user-42',
|
||||
{ role: 'owner' },
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(updated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite management', () => {
|
||||
@@ -265,7 +287,7 @@ describe('workspaceApi', () => {
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/invites/abc-token/accept',
|
||||
null,
|
||||
{ headers: AUTH_HEADER }
|
||||
{ headers: AUTH_HEADER, __skipUnifiedRemint: true }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
@@ -334,18 +356,42 @@ describe('workspaceApi', () => {
|
||||
const data = { billing_op_id: 'op-1', status: 'subscribed' }
|
||||
mockAxiosInstance.post.mockResolvedValue({ data })
|
||||
|
||||
const result = await workspaceApi.subscribe(
|
||||
'pro-monthly',
|
||||
'https://return.url',
|
||||
'https://cancel.url'
|
||||
)
|
||||
const result = await workspaceApi.subscribe('pro-monthly', {
|
||||
returnUrl: 'https://return.url',
|
||||
cancelUrl: 'https://cancel.url'
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/subscribe',
|
||||
{
|
||||
plan_slug: 'pro-monthly',
|
||||
return_url: 'https://return.url',
|
||||
cancel_url: 'https://cancel.url'
|
||||
cancel_url: 'https://cancel.url',
|
||||
team_credit_stop_id: undefined,
|
||||
billing_cycle: undefined
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
it('subscribe() sends team_credit_stop_id and billing_cycle for team plans', async () => {
|
||||
const data = { billing_op_id: 'op-1b', status: 'needs_payment_method' }
|
||||
mockAxiosInstance.post.mockResolvedValue({ data })
|
||||
|
||||
const result = await workspaceApi.subscribe('team_per_credit_annual', {
|
||||
teamCreditStopId: 'team_700',
|
||||
billingCycle: 'yearly'
|
||||
})
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/api/billing/subscribe',
|
||||
{
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
return_url: undefined,
|
||||
cancel_url: undefined,
|
||||
team_credit_stop_id: 'team_700',
|
||||
billing_cycle: 'yearly'
|
||||
},
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
WorkspaceId,
|
||||
@@ -149,13 +150,32 @@ type SubscriptionTransitionType =
|
||||
|
||||
interface PreviewSubscribeRequest {
|
||||
plan_slug: string
|
||||
team_credit_stop_id?: string
|
||||
billing_cycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
type SubscribeBillingCycle = 'monthly' | 'yearly'
|
||||
|
||||
interface SubscribeRequest {
|
||||
plan_slug: string
|
||||
idempotency_key?: string
|
||||
return_url?: string
|
||||
cancel_url?: string
|
||||
/** Required for the per-credit Team plan; selects the slider stop. */
|
||||
team_credit_stop_id?: string
|
||||
billing_cycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
returnUrl?: string
|
||||
cancelUrl?: string
|
||||
teamCreditStopId?: string
|
||||
billingCycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
export interface PreviewSubscribeOptions {
|
||||
teamCreditStopId?: string
|
||||
billingCycle?: SubscribeBillingCycle
|
||||
}
|
||||
|
||||
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
|
||||
@@ -321,6 +341,9 @@ const workspaceApiClient = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
// acceptInvite opts out via __skipUnifiedRemint (it is deliberately Firebase-authed).
|
||||
attachUnifiedRemintInterceptor(workspaceApiClient)
|
||||
|
||||
async function getAuthHeaderOrThrow() {
|
||||
return useAuthStore().getAuthHeaderOrThrow()
|
||||
}
|
||||
@@ -457,6 +480,24 @@ export const workspaceApi = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a member's role (member ↔ owner).
|
||||
* PATCH /api/workspace/members/:userId
|
||||
*/
|
||||
async updateMemberRole(userId: UserId, role: WorkspaceRole): Promise<Member> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.patch<Member>(
|
||||
api.apiURL(`/workspace/members/${userId}`),
|
||||
{ role },
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleAxiosError(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List pending invites for the workspace.
|
||||
* GET /api/workspace/invites
|
||||
@@ -519,7 +560,7 @@ export const workspaceApi = {
|
||||
const response = await workspaceApiClient.post<AcceptInviteResponse>(
|
||||
api.apiURL(`/invites/${token}/accept`),
|
||||
null,
|
||||
{ headers }
|
||||
{ headers, __skipUnifiedRemint: true }
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
@@ -582,12 +623,19 @@ export const workspaceApi = {
|
||||
* Preview subscription change
|
||||
* POST /api/billing/preview-subscribe
|
||||
*/
|
||||
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
|
||||
async previewSubscribe(
|
||||
planSlug: string,
|
||||
options: PreviewSubscribeOptions = {}
|
||||
): Promise<PreviewSubscribeResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
|
||||
api.apiURL('/billing/preview-subscribe'),
|
||||
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
|
||||
{
|
||||
plan_slug: planSlug,
|
||||
team_credit_stop_id: options.teamCreditStopId,
|
||||
billing_cycle: options.billingCycle
|
||||
} satisfies PreviewSubscribeRequest,
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
@@ -602,8 +650,7 @@ export const workspaceApi = {
|
||||
*/
|
||||
async subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
options: SubscribeOptions = {}
|
||||
): Promise<SubscribeResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
@@ -611,8 +658,10 @@ export const workspaceApi = {
|
||||
api.apiURL('/billing/subscribe'),
|
||||
{
|
||||
plan_slug: planSlug,
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl
|
||||
return_url: options.returnUrl,
|
||||
cancel_url: options.cancelUrl,
|
||||
team_credit_stop_id: options.teamCreditStopId,
|
||||
billing_cycle: options.billingCycle
|
||||
} satisfies SubscribeRequest,
|
||||
{ headers }
|
||||
)
|
||||
|
||||
192
src/platform/workspace/components/InviteMembersForm.test.ts
Normal file
192
src/platform/workspace/components/InviteMembersForm.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import InviteMembersForm from './InviteMembersForm.vue'
|
||||
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { mockCreateInvite, mockToastAdd, mockTrackInviteSent } = vi.hoisted(
|
||||
() => ({
|
||||
mockCreateInvite: vi.fn(),
|
||||
mockToastAdd: vi.fn(),
|
||||
mockTrackInviteSent: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
createInvite: mockCreateInvite as (email: string) => Promise<PendingInvite>
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackWorkspaceInviteSent: mockTrackInviteSent
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function pendingInviteFor(email: string): PendingInvite {
|
||||
return {
|
||||
id: `inv-${email}`,
|
||||
email,
|
||||
inviteDate: new Date(0),
|
||||
expiryDate: new Date(0)
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(InviteMembersForm, {
|
||||
props: {
|
||||
submitLabel: 'Send invites',
|
||||
placeholder: 'Enter emails',
|
||||
source: 'post_upgrade_success',
|
||||
...props
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return screen.getByRole('textbox')
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole('button', { name: 'Send invites' })
|
||||
}
|
||||
|
||||
describe('InviteMembersForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateInvite.mockImplementation(async (email: string) =>
|
||||
pendingInviteFor(email)
|
||||
)
|
||||
})
|
||||
|
||||
it('turns comma- and enter-delimited input into chips', async () => {
|
||||
const { user } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,')
|
||||
await user.type(emailInput(), 'c@d.com{Enter}')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('c@d.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables submit with no chips and flags invalid emails', async () => {
|
||||
const { user } = renderForm()
|
||||
|
||||
expect(submitButton()).toBeDisabled()
|
||||
|
||||
await user.type(emailInput(), 'not-an-email{Enter}')
|
||||
|
||||
expect(screen.getByText('not-an-email')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.inviteMemberDialog.invalidEmailCount')
|
||||
).toBeInTheDocument()
|
||||
expect(submitButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('creates an invite per email, tracks telemetry, and emits submitted', async () => {
|
||||
const { user, emitted } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
|
||||
await user.click(submitButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('a@b.com')
|
||||
expect(mockCreateInvite).toHaveBeenCalledWith('c@d.com')
|
||||
expect(mockTrackInviteSent).toHaveBeenCalledWith({
|
||||
source: 'post_upgrade_success',
|
||||
count: 2
|
||||
})
|
||||
expect(emitted().submitted).toEqual([[['a@b.com', 'c@d.com']]])
|
||||
})
|
||||
|
||||
it('keeps failed emails as chips, toasts, and emits the invited subset on partial failure', async () => {
|
||||
mockCreateInvite.mockImplementation(async (email: string) => {
|
||||
if (email === 'fail@x.com') throw new Error('nope')
|
||||
return pendingInviteFor(email)
|
||||
})
|
||||
const { user, emitted } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'ok@x.com,fail@x.com{Enter}')
|
||||
await user.click(submitButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('fail@x.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ok@x.com')).not.toBeInTheDocument()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(emitted().submitted).toEqual([[['ok@x.com']]])
|
||||
expect(mockTrackInviteSent).toHaveBeenCalledWith({
|
||||
source: 'post_upgrade_success',
|
||||
count: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps all chips, toasts, and emits nothing when every invite fails', async () => {
|
||||
mockCreateInvite.mockRejectedValue(new Error('nope'))
|
||||
const { user, emitted } = renderForm()
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,c@d.com{Enter}')
|
||||
await user.click(submitButton())
|
||||
|
||||
await waitFor(() => expect(mockCreateInvite).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('c@d.com')).toBeInTheDocument()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(emitted().submitted).toBeUndefined()
|
||||
expect(mockTrackInviteSent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps the number of chips at maxSeats', async () => {
|
||||
const { user } = renderForm({ maxSeats: 2 })
|
||||
|
||||
await user.type(emailInput(), 'a@b.com,b@b.com,c@b.com{Enter}')
|
||||
|
||||
expect(screen.getByText('a@b.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('b@b.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('c@b.com')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.inviteMemberDialog.seatLimitReached')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits cancel when a cancel label is provided', async () => {
|
||||
const { user, emitted } = renderForm({ cancelLabel: 'Cancel' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(emitted().cancel).toBeTruthy()
|
||||
expect(mockCreateInvite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the built-in submit row when showSubmit is false', () => {
|
||||
renderForm({ showSubmit: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Send invites' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
220
src/platform/workspace/components/InviteMembersForm.vue
Normal file
220
src/platform/workspace/components/InviteMembersForm.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TagsInput
|
||||
always-editing
|
||||
add-on-paste
|
||||
add-on-blur
|
||||
:delimiter="EMAIL_DELIMITER"
|
||||
:convert-value="normalizeEmail"
|
||||
:model-value="emails"
|
||||
class="min-h-10 w-full bg-tertiary-background px-3 focus-within:bg-tertiary-background hover:bg-tertiary-background-hover"
|
||||
@update:model-value="onEmailsUpdate"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="email in emails"
|
||||
:key="email"
|
||||
:value="email"
|
||||
:class="
|
||||
cn('rounded-full', !isValidEmail(email) && 'bg-danger/20 text-danger')
|
||||
"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:auto-focus="autoFocus"
|
||||
class="min-w-0 text-sm"
|
||||
:aria-label="placeholder"
|
||||
:aria-describedby="describedBy"
|
||||
:placeholder="emails.length === 0 ? placeholder : undefined"
|
||||
/>
|
||||
</TagsInput>
|
||||
|
||||
<p
|
||||
v-if="invalidEmails.length > 0"
|
||||
:id="invalidEmailsHintId"
|
||||
role="alert"
|
||||
class="text-danger m-0 text-xs"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.inviteMemberDialog.invalidEmailCount',
|
||||
invalidEmails.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="isAtSeatLimit"
|
||||
:id="seatLimitHintId"
|
||||
aria-live="polite"
|
||||
class="m-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.inviteMemberDialog.seatLimitReached', maxSeats) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="showSubmit"
|
||||
:class="
|
||||
cn('flex', cancelLabel ? 'items-center justify-end gap-4' : 'flex-col')
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="cancelLabel"
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="cn(!cancelLabel && 'w-full rounded-lg')"
|
||||
:loading
|
||||
:disabled="!canSubmit"
|
||||
:aria-busy="loading"
|
||||
:aria-label="loading ? $t('g.loading') : submitLabel"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { WorkspaceInviteMetadata } from '@/platform/telemetry/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import {
|
||||
EMAIL_DELIMITER,
|
||||
isValidEmail,
|
||||
normalizeEmail,
|
||||
sanitizeInviteEmails
|
||||
} from '@/platform/workspace/utils/inviteEmails'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
submitLabel,
|
||||
placeholder,
|
||||
source,
|
||||
cancelLabel,
|
||||
maxSeats = Number.POSITIVE_INFINITY,
|
||||
showSubmit = true,
|
||||
autoFocus = false
|
||||
} = defineProps<{
|
||||
submitLabel: string
|
||||
placeholder: string
|
||||
source: WorkspaceInviteMetadata['source']
|
||||
cancelLabel?: string
|
||||
maxSeats?: number
|
||||
/** Hide the built-in submit row so a parent can place the action elsewhere
|
||||
* (e.g. the team-upgrade success footer); drive it via the exposed submit. */
|
||||
showSubmit?: boolean
|
||||
/** Focus the email input on mount. Off by default so an embedding dialog
|
||||
* keeps control of its own focus order. */
|
||||
autoFocus?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [emails: string[]]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const telemetry = useTelemetry()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const emails = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const invalidEmailsHintId = useId()
|
||||
const seatLimitHintId = useId()
|
||||
|
||||
const invalidEmails = computed(() =>
|
||||
emails.value.filter((email) => !isValidEmail(email))
|
||||
)
|
||||
const isAtSeatLimit = computed(() => emails.value.length >= maxSeats)
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
emails.value.length > 0 &&
|
||||
emails.value.length <= maxSeats &&
|
||||
invalidEmails.value.length === 0
|
||||
)
|
||||
|
||||
const describedBy = computed(
|
||||
() =>
|
||||
[
|
||||
invalidEmails.value.length > 0 ? invalidEmailsHintId : undefined,
|
||||
isAtSeatLimit.value ? seatLimitHintId : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
)
|
||||
|
||||
function onEmailsUpdate(value: string[]) {
|
||||
emails.value = sanitizeInviteEmails(value, maxSeats)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
if (!canSubmit.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const emailSnapshot = [...emails.value]
|
||||
const results = await Promise.allSettled(
|
||||
emailSnapshot.map((email) => workspaceStore.createInvite(email))
|
||||
)
|
||||
const failedEmails = emailSnapshot.filter(
|
||||
(_, index) => results[index].status === 'rejected'
|
||||
)
|
||||
const invitedCount = emailSnapshot.length - failedEmails.length
|
||||
|
||||
if (invitedCount > 0) {
|
||||
telemetry?.trackWorkspaceInviteSent({ source, count: invitedCount })
|
||||
emit(
|
||||
'submitted',
|
||||
emailSnapshot.filter((email) => !failedEmails.includes(email))
|
||||
)
|
||||
}
|
||||
|
||||
if (failedEmails.length === 0) return
|
||||
|
||||
emails.value = failedEmails
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t(
|
||||
'workspacePanel.inviteMemberDialog.failedCount',
|
||||
failedEmails.length
|
||||
),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit: onSubmit,
|
||||
get canSubmit() {
|
||||
return canSubmit.value
|
||||
},
|
||||
get loading() {
|
||||
return loading.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user