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>
87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
import type { Page } from '@playwright/test'
|
|
|
|
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
|
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
|
|
|
/**
|
|
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
|
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
|
|
* must still route a not-completed user there. Drives a raw `page` so the cloud
|
|
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
|
|
* devtools backend during setup).
|
|
*/
|
|
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
|
|
|
// `/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.
|
|
async function mockSurveyNotCompleted(page: Page) {
|
|
await page.route('**/api/settings/onboarding_survey', (r) =>
|
|
r.fulfill({
|
|
status: 404,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
|
|
})
|
|
)
|
|
}
|
|
|
|
// Transient auth failure: a stale workspace token makes the authenticated
|
|
// survey check 401 — the hiccup that used to bounce working users.
|
|
async function mockSurveyTransient401(page: Page) {
|
|
await page.route('**/api/settings/onboarding_survey', (r) =>
|
|
r.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
code: 'UNAUTHORIZED',
|
|
message: 'User authentication required'
|
|
})
|
|
})
|
|
)
|
|
}
|
|
|
|
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.slow()
|
|
|
|
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
|
await mockSurveyTransient401(page)
|
|
await bootCloud(page)
|
|
|
|
await page.goto(APP_URL)
|
|
|
|
// The full app boots — CloudSurveyView is a standalone onboarding view, so
|
|
// reaching the extension manager proves we landed on the working app and
|
|
// the transient 401 was treated as "completed", not a bounce.
|
|
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
|
timeout: 45_000
|
|
})
|
|
await expect(page).not.toHaveURL(/\/cloud\/survey/)
|
|
})
|
|
|
|
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
|
page
|
|
}) => {
|
|
test.slow()
|
|
|
|
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
|
await mockSurveyNotCompleted(page)
|
|
await bootCloud(page)
|
|
|
|
await page.goto(APP_URL)
|
|
|
|
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
|
|
})
|
|
})
|