mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
## Summary Adds an in-app deep link that opens the pricing table directly, for driving pilot users straight to subscribe (request from nav/Alex). Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104). - `/?pricing=1` — on app load, open the pricing table. - `/?pricing=team` / `/?pricing=personal` — open it on the Team / Personal plan tab (via the existing `UnifiedPricingTable` `initialPlanMode`). - Gated to the **original owner** via `useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal user, or a team workspace's original owner). A member or a promoted owner is a **silent no-op**: the app loads normally, the param is stripped, no 404 / error / toast. - Off-cloud (OSS): the loader isn't instantiated, so the param is ignored. - Survives the login redirect via the preserved-query system, same as `?invite` / `?create_workspace`. ## How Mirrors the established URL-loader pattern (`useInviteUrlLoader` / `useCreateWorkspaceUrlLoader`): - `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing` namespace + tracker key. - New `usePricingTableUrlLoader.ts` — hydrate preserved query, read `pricing`, strip the param + `clearPreservedQuery` in a single replace before any await, then `await fetchMembers()` (resolves the original-owner gate; no-ops for personal) and open the table only when the gate allows. - `GraphCanvas.vue` — call the loader in `onMounted` after the create-workspace loader (cloud only; not gated on the team-workspaces flag so it also drives personal/legacy users). - `useSubscriptionDialog.ts` — new `'deep_link'` value on `SubscriptionDialogReason`. ## Telemetry Eligible opens emit the existing `subscription_required_modal_opened` PostHog event with the new `reason: 'deep_link'`. Ineligible-click bounce rate is derivable from the autocaptured pageview URL (`?pricing=…`), so no new event plumbing. ## Stacking / dependencies This feature needs two sibling stacks off `main`: - **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` + `showPricingTable({ planMode })`. - **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate. **Merged into this branch**, so the diff against the FE-934 base includes FE-770's changes until it lands. Review the single `feat(billing): deep link…` commit. Once both land on `main`, rebase onto `main` and the diff collapses to just this feature. Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up. End-state: swap the FE original-owner heuristic for the BE workspace-level `is_original_owner` flag when it lands (removes the members-fetch). ## Tests - Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an original owner; `team`/`personal` tab preselect; silent no-op + param-strip for a member/promoted owner; proves the gate is read only after `fetchMembers` resolves; preserved-query restore; empty/non-string/absent/unrecognized param; members-fetch failure strips+clears without opening. - E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`, `@cloud`, 4 cases, verified locally): personal owner opens + URL stripped; `?pricing=team` lands on the active Team tab; team original owner opens (real `is_original_owner` + email gate); team member is a silent no-op + URL stripped. - Typecheck + related unit suites (`useSubscriptionDialog`, `useWorkspaceUI`, `teamWorkspaceStore`) green. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com> Co-authored-by: github-actions <github-actions@github.com>
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
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()
|
|
})
|
|
})
|