mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 16:54:51 +00:00
Compare commits
59 Commits
codex/fix-
...
jaewon/tea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446a430b97 | ||
|
|
00822616b4 | ||
|
|
a82a3afa63 | ||
|
|
2652de6635 | ||
|
|
20b41a02ac | ||
|
|
4cd6f2f8b9 | ||
|
|
c9babcdb85 | ||
|
|
0708c01f79 | ||
|
|
0f28e1b3b1 | ||
|
|
c7faa2dbff | ||
|
|
74f47e0fd6 | ||
|
|
e0d12a33a2 | ||
|
|
dde38af722 | ||
|
|
8278c9d874 | ||
|
|
2d109b233d | ||
|
|
739b873dd6 | ||
|
|
18eced3ea0 | ||
|
|
54b869db95 | ||
|
|
ffc7643e9e | ||
|
|
0092a45983 | ||
|
|
a51183ae8a | ||
|
|
96ac29b871 | ||
|
|
a0b254b982 | ||
|
|
98e558cf3d | ||
|
|
8c098ee213 | ||
|
|
1bc10fa6dc | ||
|
|
65f5c02b16 | ||
|
|
c0f3f6fb69 | ||
|
|
a7c8f3ad82 | ||
|
|
76422f6bc2 | ||
|
|
d9f54420b6 | ||
|
|
1c97eb016c | ||
|
|
548e260be0 | ||
|
|
b4ee092fd3 | ||
|
|
6583e65dca | ||
|
|
b1d5ff8094 | ||
|
|
8526c5c872 | ||
|
|
9c6dd9c5a5 | ||
|
|
c5fd2a4117 | ||
|
|
61085d5134 | ||
|
|
c7da4241e5 | ||
|
|
64b4410365 | ||
|
|
920e43aabd | ||
|
|
68a548eb9b | ||
|
|
250a617d6c | ||
|
|
8b5e7d4ec5 | ||
|
|
0c6ca27823 | ||
|
|
7c4ffb3485 | ||
|
|
ff4a690496 | ||
|
|
2fd8f685ed | ||
|
|
72507cf225 | ||
|
|
6cadc4eb5a | ||
|
|
a013d4b123 | ||
|
|
f25ef177b8 | ||
|
|
dfd08cd13d | ||
|
|
e1bad829e7 | ||
|
|
39854ae51b | ||
|
|
a0f2445a01 | ||
|
|
30e0e0b031 |
243
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
243
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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 { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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; enable team workspaces so the
|
||||
// unified pricing table (and the lifecycle gate) are live.
|
||||
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))
|
||||
)
|
||||
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' }))
|
||||
)
|
||||
// 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.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.Assets.UseAssetAPI': false }))
|
||||
)
|
||||
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({})))
|
||||
// Queue/prompt status: a missing exec_info throws on boot and aborts the
|
||||
// GraphCanvas onMounted chain before the deep-link loader runs.
|
||||
await page.route('**/api/prompt', (r) =>
|
||||
r.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
)
|
||||
await page.route('**/api/queue', (r) =>
|
||||
r.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
)
|
||||
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([])))
|
||||
}
|
||||
|
||||
async function mockBilling(page: Page) {
|
||||
// Minimal valid shapes so the billing facade resolves while the dialog mounts.
|
||||
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' }))
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('personal', 'owner'), [])
|
||||
await bootCloud(page)
|
||||
|
||||
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.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('personal', 'owner'), [])
|
||||
await bootCloud(page)
|
||||
|
||||
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.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
await bootCloud(page)
|
||||
|
||||
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.setTimeout(60_000)
|
||||
await mockCloudBoot(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
await bootCloud(page)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -197,6 +197,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
@@ -461,6 +462,7 @@ 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 pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -587,6 +589,19 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
'[GraphCanvas] Failed to load pricing table from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
@@ -146,17 +145,6 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
||||
}))
|
||||
|
||||
const mockResolveOutputAssetItems = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue([])
|
||||
)
|
||||
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof outputAssetUtilModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mockResolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
@@ -295,8 +283,6 @@ describe('useMediaAssetActions', () => {
|
||||
mockGetOutputAssetMetadata.mockReset()
|
||||
mockGetOutputAssetMetadata.mockReturnValue(null)
|
||||
mockGetAssetType.mockReset()
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
mockResolveOutputAssetItems.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('addWorkflow', () => {
|
||||
@@ -596,236 +582,6 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - OSS multi-output expansion', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockImplementation(
|
||||
(meta: Record<string, unknown> | undefined) =>
|
||||
meta && 'jobId' in meta ? meta : null
|
||||
)
|
||||
})
|
||||
|
||||
function createOutputAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
jobId: string,
|
||||
outputCount?: number,
|
||||
previewUrl?: string
|
||||
): AssetItem {
|
||||
return createMockAsset({
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
preview_url: previewUrl ?? `https://example.com/${name}`,
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
||||
})
|
||||
}
|
||||
|
||||
it('expands a grouped asset into individual downloads', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-out1', 'out1.png', 'job1'),
|
||||
createOutputAsset('g1-out2', 'out2.png', 'job1'),
|
||||
createOutputAsset('g1-out3', 'out3.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
|
||||
expect.objectContaining({ createdAt: expect.any(String) })
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://example.com/out1.png',
|
||||
'out1.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://example.com/out2.png',
|
||||
'out2.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://example.com/out3.png',
|
||||
'out3.png'
|
||||
)
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mixes grouped and single-output assets in one selection', async () => {
|
||||
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
|
||||
const single = createOutputAsset('s1', 'solo.png', 'job2')
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-a', 'a.png', 'job1'),
|
||||
createOutputAsset('g1-b', 'b.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, single])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
|
||||
})
|
||||
|
||||
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
|
||||
const single1 = createOutputAsset(
|
||||
's1',
|
||||
'a.png',
|
||||
'job1',
|
||||
undefined,
|
||||
'https://example.com/a.png'
|
||||
)
|
||||
const single2 = createOutputAsset(
|
||||
's2',
|
||||
'b.png',
|
||||
'job2',
|
||||
1,
|
||||
'https://example.com/b.png'
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([single1, single2])
|
||||
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
|
||||
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
|
||||
const child = createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
}),
|
||||
createMockAsset({
|
||||
id: 'job1-child-b',
|
||||
name: 'out2.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out2.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, child])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['out1.png', 'out2.png'])
|
||||
})
|
||||
|
||||
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
|
||||
const failingGrouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover1.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover1.png'
|
||||
)
|
||||
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
(metadata: { jobId: string }) => {
|
||||
if (metadata.jobId === 'job1') {
|
||||
return Promise.reject(new Error('job1 lookup failed'))
|
||||
}
|
||||
return Promise.resolve([
|
||||
createOutputAsset('g2-a', 'out2a.png', 'job2'),
|
||||
createOutputAsset('g2-b', 'out2b.png', 'job2')
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([failingGrouped, okGrouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - cloud zip filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -27,10 +27,7 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
import {
|
||||
getAssetOutputCount,
|
||||
resolveOutputAssetItems
|
||||
} from '../utils/outputAssetUtil'
|
||||
import { getAssetOutputCount } from '../utils/outputAssetUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { isResultItemType } from '@/utils/typeGuardUtil'
|
||||
@@ -112,9 +109,8 @@ export function useMediaAssetActions() {
|
||||
* Download one or more assets.
|
||||
* In cloud mode, creates a ZIP export via the backend when called with
|
||||
* 2+ assets or with any asset whose job has `outputCount > 1`.
|
||||
* In OSS mode, downloads each file directly, expanding grouped assets
|
||||
* (`outputCount > 1`) into their individual outputs.
|
||||
* With no argument, uses the asset from `MediaAssetKey` context.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*/
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const targetAssets =
|
||||
@@ -131,13 +127,13 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMultiOutputJobs) {
|
||||
void downloadAssetsIndividually(targetAssets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
targetAssets.forEach((asset) => downloadSingleAsset(asset))
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
@@ -154,66 +150,6 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingleAsset(asset: AssetItem) {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
}
|
||||
|
||||
async function expandAssetForDownload(
|
||||
asset: AssetItem
|
||||
): Promise<AssetItem[]> {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (
|
||||
!metadata ||
|
||||
typeof metadata.outputCount !== 'number' ||
|
||||
metadata.outputCount <= 1
|
||||
) {
|
||||
return [asset]
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
return resolved.length > 0 ? resolved : [asset]
|
||||
} catch (error) {
|
||||
console.error('Failed to expand grouped asset for download:', error)
|
||||
return [asset]
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsIndividually(assets: AssetItem[]) {
|
||||
try {
|
||||
const expanded = await Promise.all(assets.map(expandAssetForDownload))
|
||||
const seenAssetIds = new Set<string>()
|
||||
const filesToDownload = expanded.flat().filter((asset) => {
|
||||
if (seenAssetIds.has(asset.id)) return false
|
||||
seenAssetIds.add(asset.id)
|
||||
return true
|
||||
})
|
||||
|
||||
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t(
|
||||
'mediaAsset.selection.downloadsStarted',
|
||||
filesToDownload.length
|
||||
),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to download assets:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsAsZip(assets: AssetItem[]) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
|
||||
@@ -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,6 +10,7 @@ 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'
|
||||
|
||||
@@ -35,6 +36,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,7 +65,34 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Only paid tiers can be checked out via redirect
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
const billingCycle: BillingCycle = (validCycles as string[]).includes(
|
||||
cycleParam
|
||||
)
|
||||
? (cycleParam as BillingCycle)
|
||||
: '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
|
||||
}
|
||||
|
||||
// Only paid personal tiers can be checked out via redirect
|
||||
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
|
||||
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
|
||||
await router.push('/')
|
||||
@@ -69,11 +103,6 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
selectedTierKey.value = tierKey
|
||||
|
||||
const validCycles: BillingCycle[] = ['monthly', 'yearly']
|
||||
if (!cycleParam || !(validCycles as string[]).includes(cycleParam)) {
|
||||
cycleParam = 'monthly'
|
||||
}
|
||||
|
||||
if (!isInitialized.value) {
|
||||
await initialize()
|
||||
}
|
||||
@@ -81,11 +110,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
cycleParam as BillingCycle,
|
||||
false
|
||||
)
|
||||
await performSubscriptionCheckout(tierKey, billingCycle, false)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
@@ -105,18 +130,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
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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('ignores empty param', async () => {
|
||||
mockRouteQuery.value = { pricing: '' }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-string param', async () => {
|
||||
mockRouteQuery.value = { pricing: fromAny<string, unknown>(['array']) }
|
||||
|
||||
const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,67 @@
|
||||
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 || typeof param !== 'string') return
|
||||
|
||||
// Strip the param (even for ineligible users) 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 the user.
|
||||
const cleanQuery = { ...query }
|
||||
delete cleanQuery.pricing
|
||||
router.replace({ query: cleanQuery }).catch((error) => {
|
||||
console.warn(
|
||||
'[usePricingTableUrlLoader] Failed to clean URL params:',
|
||||
error
|
||||
)
|
||||
})
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
|
||||
// Fetch members (no-ops for personal) so the original-owner self-row loads
|
||||
// before the gate; fetchMembers awaits, ensureMembersLoaded can return early.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
|
||||
@@ -49,6 +49,17 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
|
||||
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
|
||||
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
|
||||
|
||||
/**
|
||||
* 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 getTeamPlanSlug(billingCycle: 'monthly' | 'yearly'): string {
|
||||
return billingCycle === 'yearly'
|
||||
? 'team_per_credit_annual'
|
||||
: 'team_per_credit_monthly'
|
||||
}
|
||||
|
||||
/**
|
||||
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
|
||||
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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',
|
||||
'https://app.test/payment/success',
|
||||
'https://app.test/payment/failed',
|
||||
'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',
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'team_1400'
|
||||
)
|
||||
expect(assignedHref).toBe('/')
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`,
|
||||
teamCreditStopId
|
||||
)
|
||||
|
||||
if (
|
||||
response.status === 'needs_payment_method' &&
|
||||
response.payment_method_url
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -156,6 +156,8 @@ interface SubscribeRequest {
|
||||
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
|
||||
}
|
||||
|
||||
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
|
||||
@@ -603,7 +605,8 @@ export const workspaceApi = {
|
||||
async subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
cancelUrl?: string,
|
||||
teamCreditStopId?: string
|
||||
): Promise<SubscribeResponse> {
|
||||
const headers = await getAuthHeaderOrThrow()
|
||||
try {
|
||||
@@ -612,7 +615,8 @@ export const workspaceApi = {
|
||||
{
|
||||
plan_slug: planSlug,
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl
|
||||
cancel_url: cancelUrl,
|
||||
team_credit_stop_id: teamCreditStopId
|
||||
} satisfies SubscribeRequest,
|
||||
{ headers }
|
||||
)
|
||||
|
||||
@@ -116,6 +116,10 @@ installPreservedQueryTracker(router, [
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
|
||||
keys: ['oauth_request_id']
|
||||
},
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
|
||||
keys: ['pricing']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user