From 16cfb6f8bd60756b00a28a350c2019da970c4c1d Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 17 Oct 2026 00:25:41 +0800 Subject: [PATCH] feat(cloud): show purchased plan revision alongside the latest revision - Expose the workspace's purchased plan revision from the entitlement snapshot. - Stack the purchased revision card in front of the latest revision in the plan comparison dialog; both cards share the same layout, and the purchased card's copy and prices come from the Space revision endpoint while benefits stay on the entitlement snapshot. - Add a cancel-first tooltip for subscribing to the newer revision. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/cloud-plan-revisions.md | 37 ++ .../api/http/controller/groups/workspaces.py | 12 + src/langbot/pkg/cloud/entitlements.py | 4 + tests/integration/api/test_workspaces.py | 7 + .../workspace-settings/CloudPlansDialog.tsx | 475 ++++++++++++------ .../workspace-settings/PlanRevisionStack.tsx | 84 ++++ .../workspace-settings/cloud-plans.ts | 23 + .../workspace-settings/plan-revisions.ts | 48 ++ web/src/app/infra/entities/workspace.ts | 8 + web/src/i18n/locales/en-US.ts | 45 ++ web/src/i18n/locales/es-ES.ts | 45 ++ web/src/i18n/locales/ja-JP.ts | 45 ++ web/src/i18n/locales/ru-RU.ts | 45 ++ web/src/i18n/locales/th-TH.ts | 44 ++ web/src/i18n/locales/vi-VN.ts | 45 ++ web/src/i18n/locales/zh-Hans.ts | 42 ++ web/src/i18n/locales/zh-Hant.ts | 42 ++ web/tests/revisions/index.html | 1 + web/tests/revisions/preview.tsx | 42 ++ web/tests/revisions/verify.mjs | 72 +++ web/tests/revisions/vite.config.mjs | 5 + web/tests/unit/plan-revisions.test.mjs | 27 + 22 files changed, 1054 insertions(+), 144 deletions(-) create mode 100644 docs/architecture/cloud-plan-revisions.md create mode 100644 web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx create mode 100644 web/src/app/home/components/workspace-settings/plan-revisions.ts create mode 100644 web/tests/revisions/index.html create mode 100644 web/tests/revisions/preview.tsx create mode 100644 web/tests/revisions/verify.mjs create mode 100644 web/tests/revisions/vite.config.mjs create mode 100644 web/tests/unit/plan-revisions.test.mjs diff --git a/docs/architecture/cloud-plan-revisions.md b/docs/architecture/cloud-plan-revisions.md new file mode 100644 index 000000000..2cc7d3c37 --- /dev/null +++ b/docs/architecture/cloud-plan-revisions.md @@ -0,0 +1,37 @@ +## Cloud plan comparison + +`EntitlementSnapshot.plan_revision` and `monthly_bonus_credits` are optional +signed display metadata. The Space adapter opts into these fields with +`plan_metadata=2`. `/api/v1/workspaces/current` exposes them as `current_plan`, +alongside the snapshot's effective limits and feature switches. The existing +Workspace permission check and entitlement resolver remain authoritative. +Admission and quotas do not use plan names or display metadata. + +The comparison dialog shows the purchased revision even when the catalog has +no matching plan. It compares resource limits, retention, credits, platform +restrictions and feature switches; identical benefits use one column even if +the revision changed. Missing signed metadata is an explicit unavailable state. +Purchase links for existing paid plans go to Space subscription management, +where permissions, cancellation and revision eligibility are checked again. + +This adds display information to an existing authenticated response, not an +agent operation. There is no new MCP operation or billing mutation in Core. +Roll out the matching Core snapshot model before the updated Space adapter. + +Verification: + +```sh +uv run pytest tests/unit_tests/cloud/test_entitlements.py tests/integration/api/test_workspaces.py -q +cd web +node --test tests/unit/cloud-plans.test.mjs tests/unit/plan-revisions.test.mjs +pnpm build +node tests/revisions/verify.mjs +``` + +The browser runner expects sibling `space` and `core` checkouts and uses isolated +fixtures and mocked catalog responses. It never authenticates to real Space or +initiates a real payment, and shuts down its own servers after the tests. + +Purchased benefits are a card in the existing horizontally scrollable plan card area, immediately before the matching latest plan. There is no separate revision comparison panel, and historical prices are not inferred from the latest catalog. + +Customer presentation uses subscription dates and “Latest quotas for this plan”, never revision numbers. Unrelated plans and current latest subscriptions have no comparison markers. Core reuses its normal Benefits card content without diff rows or highlighting. Internal entitlement feature keys are not rendered as product benefits. The optional signed subscription_started_at is the current subscription period start, returned only for a matching entitlement revision; it is not snapshot issuance time. Requests with plan_metadata=1 retain their previous response shape. diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 020d2f7fd..4f653a2e3 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -160,6 +160,7 @@ class WorkspacesRouterGroup(group.RouterGroup): workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) plan_name: str | None = None limitation = None + current_plan = None resolver = getattr(self.ap, 'entitlement_resolver', None) if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None: entitlement = await resolver.resolve( @@ -167,6 +168,15 @@ class WorkspacesRouterGroup(group.RouterGroup): minimum_revision=request_context.entitlement_revision, ) plan_name = entitlement.plan_name + if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None: + current_plan = { + 'name': entitlement.plan_name, + 'subscription_started_at': entitlement.subscription_started_at, + 'revision': entitlement.plan_revision, + 'monthly_bonus_credits': entitlement.monthly_bonus_credits, + 'limits': entitlement.limits, + 'entitlement_features': entitlement.features, + } from ...service.workspace_limits import LIMIT_KEYS limitation = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()} @@ -188,6 +198,7 @@ class WorkspacesRouterGroup(group.RouterGroup): 'permissions': sorted(request_context.workspace.permissions), 'placement_generation': request_context.placement_generation, 'plan_name': plan_name, + 'current_plan': current_plan, 'limitation': limitation, } ) @@ -205,6 +216,7 @@ class WorkspacesRouterGroup(group.RouterGroup): 'permissions': sorted(request_context.workspace.permissions), 'placement_generation': request_context.placement_generation, 'plan_name': plan_name, + 'current_plan': current_plan, 'limitation': limitation, } ) diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py index d6d34afd5..7787f7ae1 100644 --- a/src/langbot/pkg/cloud/entitlements.py +++ b/src/langbot/pkg/cloud/entitlements.py @@ -55,6 +55,10 @@ class EntitlementSnapshot(pydantic.BaseModel): # must continue to use generic ``features`` and ``limits`` exclusively. plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128) + subscription_started_at: int | None = pydantic.Field(default=None, ge=0) + plan_revision: int | None = pydantic.Field(default=None, ge=1) + monthly_bonus_credits: int | None = pydantic.Field(default=None, ge=0) + @pydantic.field_validator('features') @classmethod def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]: diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index aba1765cc..dd2baf961 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -824,6 +824,9 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in not_before=1, expires_at=4102444800, plan_name='free', + plan_revision=3, + monthly_bonus_credits=12, + subscription_started_at=1720000000, limits={'bots.max': 2, 'pipelines.max': 3, 'plugins.max': 4, 'knowledge_bases.max': 5}, ) @@ -845,6 +848,10 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in assert current['placement_generation'] == 12 assert current['plan_name'] == 'free' assert current['limitation']['max_bots'] == 2 + assert current['current_plan']['subscription_started_at'] == 1720000000 + assert current['current_plan']['revision'] == 3 + assert current['current_plan']['monthly_bonus_credits'] == 12 + assert current['current_plan']['limits']['bots.max'] == 2 create_workspace = await client.post( '/api/v1/workspaces', diff --git a/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx b/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx index 87a0ffe24..8e753b18b 100644 --- a/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx +++ b/web/src/app/home/components/workspace-settings/CloudPlansDialog.tsx @@ -32,12 +32,15 @@ import { cloudSubscriptionManagementURL, cloudPlanPrice, cloudPlanCurrencyForLanguage, + fetchCloudPlanRevision, fetchPublicCloudPlans, localizedPlanText, type CloudPlanBillingCycle, type PublicCloudPlan, } from './cloud-plans'; +import { revisionRows } from './plan-revisions'; import { supportedPlanPlatforms } from './cloud-plan-platforms'; +import PlanRevisionStack from './PlanRevisionStack'; function PlanIcon({ name }: { name?: string }) { const key = name @@ -68,9 +71,9 @@ function Help({ label, children }: { label: string; children: ReactNode }) { @@ -84,7 +87,15 @@ function Benefits({ plan, spaceURL, }: { - plan: PublicCloudPlan; + plan: Pick< + PublicCloudPlan, + | 'limits' + | 'features' + | 'disabled_adapters' + | 'display_config' + | 'chat_history_retention_days' + | 'monthly_bonus_credits' + >; spaceURL: string; }) { const { t, i18n } = useTranslation(); @@ -116,37 +127,39 @@ function Benefits({ quota_summary: ( <> - {quotaLabel} + {quotaLabel} {text(config?.tooltips?.quota_summary) && (

{text(config?.tooltips?.quota_summary)}

)}
    - {Object.entries(plan.limits).map(([key, limit]) => { - const resource = key.replace(/\.max$/, ''); - const label = - text(config?.quotas?.[key]?.label) || - t(`cloudPlans.limits.${resource}`, { - defaultValue: resource.replace(/_/g, ' '), - }); - return ( -
  • -
    - {label} - - {limit < 0 - ? t('cloudPlans.unlimited') - : limit.toLocaleString()} - -
    - {text(config?.quotas?.[key]?.tooltip) && ( -

    - {text(config?.quotas?.[key]?.tooltip)} -

    - )} -
  • - ); - })} + {Object.entries(plan.limits) + .filter(([key]) => key !== 'chat_history.retention_days') + .map(([key, limit]) => { + const resource = key.replace(/\.max$/, ''); + const label = + text(config?.quotas?.[key]?.label) || + t(`cloudPlans.limits.${resource}`, { + defaultValue: resource.replace(/_/g, ' '), + }); + return ( +
  • +
    + {label} + + {limit < 0 + ? t('cloudPlans.unlimited') + : limit.toLocaleString()} + +
    + {text(config?.quotas?.[key]?.tooltip) && ( +

    + {text(config?.quotas?.[key]?.tooltip)} +

    + )} +
  • + ); + })}
@@ -154,7 +167,7 @@ function Benefits({ retention: ( <> - + {t('cloudPlans.retention', { count: plan.chat_history_retention_days, })} @@ -167,7 +180,7 @@ function Benefits({ monthly_credits: ( <> - + {t('cloudPlans.credits', { count: plan.monthly_bonus_credits })} @@ -179,7 +192,7 @@ function Benefits({ supported_platforms: ( <> - {t('cloudPlans.platforms')} + {t('cloudPlans.platforms')} - {label} + {label} {text(item.tooltip) && {text(item.tooltip)}} ); @@ -303,6 +316,94 @@ export default function CloudPlansDialog({ window.clearTimeout(timeout); }; }, [open, spaceURL, attempt]); + const owned = workspace.current_plan; + const latest = plans.find((plan) => plan.name === owned?.name); + // Catalog copy of the purchased revision, so its card renders exactly like + // the latest one. Benefits still come from the entitlement snapshot below. + const [ownedRevision, setOwnedRevision] = useState( + null, + ); + const ownedName = owned?.name; + const ownedRevisionNumber = owned?.revision; + const needsOwnedRevision = + !!latest && !!ownedName && latest.revision !== ownedRevisionNumber; + useEffect(() => { + setOwnedRevision(null); + if (!open || !needsOwnedRevision || !ownedName || !ownedRevisionNumber) + return; + const controller = new AbortController(); + fetchCloudPlanRevision( + spaceURL, + ownedName, + ownedRevisionNumber, + controller.signal, + ) + .then(setOwnedRevision) + // Fall back to the snapshot-only card when the revision is unavailable. + .catch(() => {}); + return () => controller.abort(); + }, [open, spaceURL, needsOwnedRevision, ownedName, ownedRevisionNumber]); + const rows = owned ? revisionRows(owned, latest) : []; + const distinct = + !!owned && + (!latest || + (owned.revision !== latest.revision && rows.some((row) => row.changed))); + const entries: (PublicCloudPlan | 'owned')[] = [...plans]; + if (distinct) { + const index = entries.findIndex( + (plan) => plan !== 'owned' && plan.name === owned.name, + ); + entries.splice(index < 0 ? 0 : index, 0, 'owned'); + } + // Copy, prices and display items come from the purchased revision itself; + // limits, platforms, retention and credits come only from the workspace + // entitlement snapshot, never from the latest revision. + const ownedCatalog = + ownedRevision?.revision === owned?.revision ? ownedRevision : null; + const ownedPlan: PublicCloudPlan | null = + distinct && owned + ? { + uuid: `owned-${owned.name}-${owned.revision}`, + name: owned.name, + revision: owned.revision, + display_name: + ownedCatalog?.display_name ?? latest?.display_name ?? {}, + description: ownedCatalog?.description ?? {}, + price_cny: ownedCatalog?.price_cny ?? '0', + price_usd: ownedCatalog?.price_usd ?? '0', + price_jpy: ownedCatalog?.price_jpy ?? '0', + limits: owned.limits, + features: ownedCatalog?.features ?? [], + is_active: true, + sort_order: latest?.sort_order ?? 0, + highlight: ownedCatalog?.highlight ?? false, + chat_history_retention_days: + owned.limits['chat_history.retention_days'] ?? 30, + monthly_bonus_credits: owned.monthly_bonus_credits, + disabled_adapters: Object.entries(owned.entitlement_features) + .filter(([key, enabled]) => key.startsWith('platform.') && !enabled) + .map(([key]) => key.slice(9)), + display_config: ownedCatalog?.display_config + ? ownedCatalog.display_config + : latest?.display_config + ? { + icon: latest.display_config.icon, + order: latest.display_config.order, + tooltips: latest.display_config.tooltips, + quotas: latest.display_config.quotas, + items: [], + } + : { items: [] }, + } + : null; + const subscribedOn = owned?.subscription_started_at + ? t('planRevision.subscribedOn', { + interpolation: { escapeValue: false }, + date: new Date(owned.subscription_started_at * 1000).toLocaleDateString( + i18n.language.replace('_', '-'), + ), + }) + : null; const formatPrice = (price: number) => new Intl.NumberFormat(i18n.language.replace('_', '-'), { style: 'currency', @@ -344,6 +445,15 @@ export default function CloudPlansDialog({
+ {!loading && ( + <> + {!workspace.current_plan && ( +

+ {t('planRevision.unknown')} +

+ )} + + )} {loading ? (
- ) : !plans.length ? ( + ) : !entries.length ? (

{t('cloudPlans.empty')}

@@ -371,120 +481,197 @@ export default function CloudPlansDialog({
2 && 'lg:grid-cols-3', + 'flex gap-4 overflow-x-auto p-1 [&>*]:w-72 [&>*]:shrink-0 [&>*]:grow', )} > - {plans.map((plan) => { - const current = workspace.plan_name === plan.name; - const downgradeBlocked = - Boolean( - workspace.plan_name && workspace.plan_name !== 'free', - ) && plan.name === 'free'; - const price = cloudPlanPrice(plan, cycle, currency); - const name = - localizedPlanText(plan.display_name, i18n.language) || - plan.name.toUpperCase(); - return ( -
-
-

- - {name} -

- {current ? ( - - {t('cloudPlans.current')} - - ) : plan.highlight ? ( - {t('cloudPlans.recommended')} - ) : null} -
-

- {localizedPlanText(plan.description, i18n.language)} -

-

- {price === 0 - ? t('cloudPlans.free') - : formatPrice(price)} - {price > 0 && ( - - / {t(`cloudPlans.${cycle}`)} - + {entries.map((plan) => { + const renderCard = ( + plan: PublicCloudPlan, + ownedCard = false, + ) => { + const samePlan = workspace.plan_name === plan.name; + const current = + ownedCard || (samePlan && !distinct && !!owned); + // Both stacked revisions share one card style; only the + // badge text tells them apart. + const stacked = distinct && plan.name === owned?.name; + const downgradeBlocked = + Boolean( + workspace.plan_name && workspace.plan_name !== 'free', + ) && plan.name === 'free'; + const price = cloudPlanPrice(plan, cycle, currency); + const name = + localizedPlanText(plan.display_name, i18n.language) || + plan.name.toUpperCase(); + return ( +

-

- {cycle === 'annual' && price > 0 - ? t('cloudPlans.annualNote', { - price: formatPrice(price / 12), - }) - : ''} -

-
- -
- {plan.subscription_disabled && ( -

- {t('cloudPlans.subscriptionUnavailableHint')} -

- )} - {plan.subscription_disabled && !current ? ( - - ) : downgradeBlocked ? null : !canManage ? ( - - ) : ( - - )} -
+ {ownedCard && subscribedOn && ( + + {subscribedOn} + + )} +

+ {ownedCard && !ownedCatalog ? ( +
+ ) : ( + <> +

+ {price === 0 + ? t('cloudPlans.free') + : formatPrice(price)} + {price > 0 && ( + + / {t(`cloudPlans.${cycle}`)} + + )} +

+

+ {cycle === 'annual' && price > 0 + ? t('cloudPlans.annualNote', { + price: formatPrice(price / 12), + }) + : ''} +

+ + )} +
+ +
+ {plan.subscription_disabled && ( +

+ {t('cloudPlans.subscriptionUnavailableHint')} +

+ )} + {plan.subscription_disabled && !current ? ( + + ) : downgradeBlocked ? null : !canManage ? ( + + ) : distinct && + !ownedCard && + plan.name === owned?.name ? ( + + + + + + {t('planRevision.cancelToSubscribe')} + + + ) : ( + + )} + {ownedCard && !latest && ( +

+ {t('planRevision.unavailable')} +

+ )} +
+ ); + }; + if (plan === 'owned') + return ownedPlan && !latest + ? renderCard(ownedPlan, true) + : null; + return ownedPlan && latest === plan ? ( + + ) : ( + renderCard(plan) ); })}
diff --git a/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx b/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx new file mode 100644 index 000000000..22e5130a2 --- /dev/null +++ b/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx @@ -0,0 +1,84 @@ +import { useState, type ReactNode } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +type Layer = 'current' | 'latest'; + +/** + * Stacks the purchased revision in front of the plan's latest revision. The + * toggle only changes which card is shown; it never changes the subscription. + * Layers stay opaque; the back card is dimmed with an overlay instead of + * opacity so the two cards never show through each other while swapping. + */ +export default function PlanRevisionStack({ + current, + latest, +}: { + current: ReactNode; + latest: ReactNode; +}) { + const { t } = useTranslation(); + const [front, setFront] = useState('current'); + const back: Layer = front === 'current' ? 'latest' : 'current'; + const label = t( + back === 'latest' ? 'planRevision.showLatest' : 'planRevision.showCurrent', + ); + const layer = (key: Layer, node: ReactNode) => { + const isFront = front === key; + return ( +
article]:flex-1', + isFront + ? 'z-10 after:opacity-0' + : 'pointer-events-none z-0 translate-x-8 scale-[0.95] after:opacity-50 [&>article]:shadow-none', + )} + > + {node} +
+ ); + }; + return ( +
+ {layer('current', current)} + {layer('latest', latest)} + +
+ ); +} diff --git a/web/src/app/home/components/workspace-settings/cloud-plans.ts b/web/src/app/home/components/workspace-settings/cloud-plans.ts index f70d5dd2b..1ad7a392a 100644 --- a/web/src/app/home/components/workspace-settings/cloud-plans.ts +++ b/web/src/app/home/components/workspace-settings/cloud-plans.ts @@ -2,6 +2,7 @@ export type CloudPlanBillingCycle = 'monthly' | 'annual'; export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY'; export type PlanLocalizedText = Partial>; export interface PublicCloudPlan { + entitlement_features?: Record; uuid: string; name: string; revision: number; @@ -158,3 +159,25 @@ export async function fetchPublicCloudPlans( } return plans.sort((a, b) => a.sort_order - b.sort_order); } + +// Catalog metadata (copy, prices, display items) of one historical revision. +export async function fetchCloudPlanRevision( + base: string, + name: string, + revision: number, + signal: AbortSignal, +): Promise { + const response = await fetch( + spaceURL( + base, + `/api/v1/cloud/v2/plans/${encodeURIComponent(name)}/revisions/${revision}`, + ), + { credentials: 'omit', referrerPolicy: 'no-referrer', signal }, + ); + if (!response.ok) throw new Error('Could not load plan revision'); + const result = await response.json(); + const plan = result?.data as PublicCloudPlan | undefined; + if (result?.code !== 0 || !plan?.name || plan.name !== name) + throw new Error('Invalid plan revision'); + return plan; +} diff --git a/web/src/app/home/components/workspace-settings/plan-revisions.ts b/web/src/app/home/components/workspace-settings/plan-revisions.ts new file mode 100644 index 000000000..0c5cf31ec --- /dev/null +++ b/web/src/app/home/components/workspace-settings/plan-revisions.ts @@ -0,0 +1,48 @@ +/** Only trusted subscription data may populate the current side of a comparison. */ +export interface PlanRevisionBenefits { + name: string; + revision: number; + limits: Record; + entitlement_features?: Record; + disabled_adapters?: string[]; + chat_history_retention_days?: number; + monthly_bonus_credits?: number; +} + +export function benefitValues(plan: PlanRevisionBenefits) { + const values: Record = {}; + for (const [key, value] of Object.entries(plan.limits)) + values[`limit:${key}`] = value; + values['limit:chat_history.retention_days'] = + plan.limits['chat_history.retention_days'] ?? + plan.chat_history_retention_days ?? + 30; + values.monthly_credits = plan.monthly_bonus_credits ?? 0; + for (const [key, value] of Object.entries(plan.entitlement_features ?? {})) + values[`feature:${key}`] = value; + for (const adapter of plan.disabled_adapters ?? []) + values[`feature:platform.${adapter}`] = false; + return values; +} + +export function revisionRows( + current: PlanRevisionBenefits, + latest?: PlanRevisionBenefits, +) { + const left = benefitValues(current); + const right = latest ? benefitValues(latest) : {}; + return [...new Set([...Object.keys(left), ...Object.keys(right)])] + .sort() + .map((key) => { + // Platform adapters are allowed unless explicitly disabled. Other feature + // switches must be explicitly granted, matching Core's admission semantics. + const fallback = key.startsWith('feature:platform.') + ? true + : key.startsWith('feature:') + ? false + : undefined; + const before = left[key] ?? fallback; + const after = right[key] ?? fallback; + return { key, before, after, changed: !!latest && before !== after }; + }); +} diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts index f08d7ab75..f5b720558 100644 --- a/web/src/app/infra/entities/workspace.ts +++ b/web/src/app/infra/entities/workspace.ts @@ -24,6 +24,14 @@ export interface WorkspaceMembership { } export interface CurrentWorkspace { + current_plan?: { + subscription_started_at?: number | null; + name: string; + revision: number; + limits: Record; + entitlement_features: Record; + monthly_bonus_credits: number; + } | null; workspace: Workspace; membership: WorkspaceMembership; permissions: string[]; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b5d6733d5..092582e22 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,6 +1,51 @@ import slackSetup from './slack-setup/en-US'; import pipelineMigration from './pipeline-migration/en-US'; const enUS = { + planRevision: { + subscribedOn: 'Subscribed on {{date}}', + latestQuota: 'Latest quotas for this plan', + showLatest: 'Show latest quotas for this plan', + showCurrent: 'Show your current subscription', + latestTab: 'Latest', + currentTab: 'Current', + cancelToSubscribe: + 'Cancel your subscription to subscribe to the latest quota next period', + + current: 'Current subscription: {{plan}} · revision {{revision}}', + owned: 'Purchased revision {{revision}}', + latest: 'Latest revision {{revision}}', + changed: + 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.', + same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.', + unavailable: + 'The latest plan is unavailable. Your purchased benefits remain in effect.', + benefit: 'Benefit', + change: 'Changed', + enabled: 'Enabled', + disabled: 'Disabled', + monthlyCredits: 'Monthly bonus credits', + retention: 'Chat history retention (days)', + unknown: + 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.', + handoff: + 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.', + cancelFirst: + 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.', + canceled: + 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.', + prepaid: + 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.', + renew: 'Renew current subscription', + buying: 'Purchasing revision {{revision}}.', + fields: { + bots_max: 'Bots', + members_max: 'Members', + pipelines_max: 'Processors', + plugins_max: 'Extensions', + knowledge_bases_max: 'Knowledge bases', + managed_sandbox_sessions_max: 'Sandbox sessions', + }, + }, cloudPlans: { subscriptionUnavailable: 'Not available to subscribe', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index c7475dada..f791c2ed3 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1,5 +1,50 @@ import pipelineMigration from './pipeline-migration/es-ES'; const esES = { + planRevision: { + subscribedOn: 'Fecha de suscripción: {{date}}', + latestQuota: 'Cuotas más recientes de este plan', + showLatest: 'Ver las cuotas más recientes de este plan', + showCurrent: 'Ver tu suscripción actual', + latestTab: 'Más reciente', + currentTab: 'Actual', + cancelToSubscribe: + 'Cancela tu suscripción para acceder a las últimas cuotas el próximo período', + + current: 'Current subscription: {{plan}} · revision {{revision}}', + owned: 'Purchased revision {{revision}}', + latest: 'Latest revision {{revision}}', + changed: + 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.', + same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.', + unavailable: + 'The latest plan is unavailable. Your purchased benefits remain in effect.', + benefit: 'Benefit', + change: 'Changed', + enabled: 'Enabled', + disabled: 'Disabled', + monthlyCredits: 'Monthly bonus credits', + retention: 'Chat history retention (days)', + unknown: + 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.', + handoff: + 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.', + cancelFirst: + 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.', + canceled: + 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.', + prepaid: + 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.', + renew: 'Renovar la suscripción actual', + buying: 'Purchasing revision {{revision}}.', + fields: { + bots_max: 'Bots', + members_max: 'Members', + pipelines_max: 'Processors', + plugins_max: 'Extensions', + knowledge_bases_max: 'Knowledge bases', + managed_sandbox_sessions_max: 'Sandbox sessions', + }, + }, cloudPlans: { subscriptionUnavailable: 'Suscripción no disponible', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 0099d4c80..6e9b40eed 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1,6 +1,51 @@ import slackSetup from './slack-setup/ja-JP'; import pipelineMigration from './pipeline-migration/ja-JP'; const jaJP = { + planRevision: { + subscribedOn: '契約日:{{date}}', + latestQuota: 'このプランの最新の利用枠', + showLatest: 'このプランの最新クォータを表示', + showCurrent: '現在のサブスクリプションを表示', + latestTab: '最新', + currentTab: '現在', + cancelToSubscribe: + '現在のサブスクリプションをキャンセルすると、次期から最新のクォータに登録できます', + + current: '現在の契約:{{plan}} · revision {{revision}}', + owned: '購入済み revision {{revision}}', + latest: '最新 revision {{revision}}', + changed: + '購入済みの特典と最新バージョンに違いがあります。変更箇所を強調表示しています。', + same: '特典は最新 revision {{revision}} と同じです。購入済みバージョンは変更されません。', + unavailable: + '最新プランは現在利用できません。購入済みの特典は引き続き有効です。', + benefit: '特典', + change: '変更あり', + enabled: '有効', + disabled: '無効', + monthlyCredits: '毎月のボーナスクレジット', + retention: 'チャット履歴の保存日数', + unknown: + '現在の契約情報を取得できません。再読み込みするか Space で契約を管理してください。最新カタログの特典は購入済み特典ではありません。', + handoff: + 'Space で契約を管理してください。新バージョンの購入前に自動更新を解除してください。前払い契約は期限前に更新しなければ自動終了します。', + cancelFirst: + 'まず契約管理で自動更新を解除してください。支払済みの特典は期間終了まで保持され、返金はありません。解除の確認後、最新バージョンを購入でき、決済成功時に新しい特典が有効になります。', + canceled: + '更新は解除済みです。支払済みの特典は期間終了まで保持され、返金はありません。新しい購入の特典は決済成功時に有効になります。', + prepaid: + 'この前払い契約は期間終了時に自動終了します。期限前は現在の内容で更新でき、期限後は各プランの最新の利用枠でのみ契約できます。', + renew: '現在の契約を更新', + buying: 'revision {{revision}} を購入します。', + fields: { + bots_max: 'ボット', + members_max: 'メンバー', + pipelines_max: 'プロセッサー', + plugins_max: '拡張機能', + knowledge_bases_max: 'ナレッジベース', + managed_sandbox_sessions_max: 'サンドボックスセッション', + }, + }, cloudPlans: { subscriptionUnavailable: '現在お申し込みできません', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 9c26c0f4f..132fda288 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1,5 +1,50 @@ import pipelineMigration from './pipeline-migration/ru-RU'; const ruRU = { + planRevision: { + subscribedOn: 'Дата подписки: {{date}}', + latestQuota: 'Актуальные квоты этого плана', + showLatest: 'Показать актуальные квоты этого тарифа', + showCurrent: 'Показать текущую подписку', + latestTab: 'Новые', + currentTab: 'Текущая', + cancelToSubscribe: + 'Отмените подписку, чтобы получить доступ к новым квотам со следующего периода', + + current: 'Current subscription: {{plan}} · revision {{revision}}', + owned: 'Purchased revision {{revision}}', + latest: 'Latest revision {{revision}}', + changed: + 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.', + same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.', + unavailable: + 'The latest plan is unavailable. Your purchased benefits remain in effect.', + benefit: 'Benefit', + change: 'Changed', + enabled: 'Enabled', + disabled: 'Disabled', + monthlyCredits: 'Monthly bonus credits', + retention: 'Chat history retention (days)', + unknown: + 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.', + handoff: + 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.', + cancelFirst: + 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.', + canceled: + 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.', + prepaid: + 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.', + renew: 'Продлить текущую подписку', + buying: 'Purchasing revision {{revision}}.', + fields: { + bots_max: 'Bots', + members_max: 'Members', + pipelines_max: 'Processors', + plugins_max: 'Extensions', + knowledge_bases_max: 'Knowledge bases', + managed_sandbox_sessions_max: 'Sandbox sessions', + }, + }, cloudPlans: { subscriptionUnavailable: 'Подписка недоступна', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 1658976a3..92e591ebb 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1,5 +1,49 @@ import pipelineMigration from './pipeline-migration/th-TH'; const thTH = { + planRevision: { + subscribedOn: 'วันที่สมัคร: {{date}}', + latestQuota: 'โควตาล่าสุดของแผนนี้', + showLatest: 'ดูโควตาล่าสุดของแผนนี้', + showCurrent: 'ดูการสมัครสมาชิกปัจจุบัน', + latestTab: 'ล่าสุด', + currentTab: 'ปัจจุบัน', + cancelToSubscribe: 'ยกเลิกการสมัครสมาชิกเพื่อรับโควต้าล่าสุดในรอบถัดไป', + + current: 'Current subscription: {{plan}} · revision {{revision}}', + owned: 'Purchased revision {{revision}}', + latest: 'Latest revision {{revision}}', + changed: + 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.', + same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.', + unavailable: + 'The latest plan is unavailable. Your purchased benefits remain in effect.', + benefit: 'Benefit', + change: 'Changed', + enabled: 'Enabled', + disabled: 'Disabled', + monthlyCredits: 'Monthly bonus credits', + retention: 'Chat history retention (days)', + unknown: + 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.', + handoff: + 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.', + cancelFirst: + 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.', + canceled: + 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.', + prepaid: + 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.', + renew: 'ต่ออายุการสมัครปัจจุบัน', + buying: 'Purchasing revision {{revision}}.', + fields: { + bots_max: 'Bots', + members_max: 'Members', + pipelines_max: 'Processors', + plugins_max: 'Extensions', + knowledge_bases_max: 'Knowledge bases', + managed_sandbox_sessions_max: 'Sandbox sessions', + }, + }, cloudPlans: { subscriptionUnavailable: 'ยังไม่เปิดรับการสมัคร', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 5db80b39e..ebd9faf67 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1,5 +1,50 @@ import pipelineMigration from './pipeline-migration/vi-VN'; const viVN = { + planRevision: { + subscribedOn: 'Ngày đăng ký: {{date}}', + latestQuota: 'Hạn mức mới nhất của gói này', + showLatest: 'Xem hạn mức mới nhất của gói này', + showCurrent: 'Xem gói đăng ký hiện tại', + latestTab: 'Mới nhất', + currentTab: 'Hiện tại', + cancelToSubscribe: + 'Hủy gói hiện tại để đăng ký hạn mức mới nhất vào kỳ tiếp theo', + + current: 'Current subscription: {{plan}} · revision {{revision}}', + owned: 'Purchased revision {{revision}}', + latest: 'Latest revision {{revision}}', + changed: + 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.', + same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.', + unavailable: + 'The latest plan is unavailable. Your purchased benefits remain in effect.', + benefit: 'Benefit', + change: 'Changed', + enabled: 'Enabled', + disabled: 'Disabled', + monthlyCredits: 'Monthly bonus credits', + retention: 'Chat history retention (days)', + unknown: + 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.', + handoff: + 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.', + cancelFirst: + 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.', + canceled: + 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.', + prepaid: + 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.', + renew: 'Gia hạn gói đăng ký hiện tại', + buying: 'Purchasing revision {{revision}}.', + fields: { + bots_max: 'Bots', + members_max: 'Members', + pipelines_max: 'Processors', + plugins_max: 'Extensions', + knowledge_bases_max: 'Knowledge bases', + managed_sandbox_sessions_max: 'Sandbox sessions', + }, + }, cloudPlans: { subscriptionUnavailable: 'Chưa thể đăng ký', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index fed8f4b7f..4d4c3d0fc 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,6 +1,48 @@ import slackSetup from './slack-setup/zh-Hans'; import pipelineMigration from './pipeline-migration/zh-Hans'; const zhHans = { + planRevision: { + subscribedOn: '订阅日期:{{date}}', + latestQuota: '该计划最新配额', + showLatest: '查看该计划最新配额', + showCurrent: '查看当前订阅', + latestTab: '最新配额', + currentTab: '当前订阅', + cancelToSubscribe: '取消本期订阅,下一期即可订阅最新配额', + + current: '当前订阅:{{plan}} · revision {{revision}}', + owned: '已购买 revision {{revision}}', + latest: '最新 revision {{revision}}', + changed: '已购买权益与最新版本存在差异,高亮行标示变动。', + same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。', + unavailable: '最新计划暂不可用,已购买权益继续有效。', + benefit: '权益', + change: '有变化', + enabled: '启用', + disabled: '禁用', + monthlyCredits: '每月赠送额度', + retention: '聊天记录保留天数', + unknown: + '当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。', + handoff: + '请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。', + cancelFirst: + '请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。', + canceled: + '续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。', + prepaid: + '本预付订阅到期自动结束。到期前可续订当前方案;到期后只能按各计划的最新配额订阅。', + renew: '续订当前订阅', + buying: '正在购买 revision {{revision}}。', + fields: { + bots_max: '机器人', + members_max: '成员', + pipelines_max: '处理器', + plugins_max: '扩展', + knowledge_bases_max: '知识库', + managed_sandbox_sessions_max: '沙箱会话', + }, + }, cloudPlans: { subscriptionUnavailable: '暂不可订阅', subscriptionUnavailableHint: diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index da7b8f831..49b2ce982 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1,6 +1,48 @@ import slackSetup from './slack-setup/zh-Hant'; import pipelineMigration from './pipeline-migration/zh-Hant'; const zhHant = { + planRevision: { + subscribedOn: '訂閱日期:{{date}}', + latestQuota: '該方案最新配額', + showLatest: '查看該計畫最新配額', + showCurrent: '查看目前訂閱', + latestTab: '最新配額', + currentTab: '目前訂閱', + cancelToSubscribe: '取消本期訂閱,下一期即可訂閱最新配額', + + current: '当前订阅:{{plan}} · revision {{revision}}', + owned: '已购买 revision {{revision}}', + latest: '最新 revision {{revision}}', + changed: '已购买权益与最新版本存在差异,高亮行标示变动。', + same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。', + unavailable: '最新计划暂不可用,已购买权益继续有效。', + benefit: '权益', + change: '有变化', + enabled: '启用', + disabled: '禁用', + monthlyCredits: '每月赠送额度', + retention: '聊天记录保留天数', + unknown: + '当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。', + handoff: + '请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。', + cancelFirst: + '请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。', + canceled: + '续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。', + prepaid: + '本預付訂閱到期自動結束。到期前可續訂目前方案;到期後只能按各方案的最新配額訂閱。', + renew: '續訂目前訂閱', + buying: '正在购买 revision {{revision}}。', + fields: { + bots_max: '机器人', + members_max: '成员', + pipelines_max: '处理器', + plugins_max: '扩展', + knowledge_bases_max: '知识库', + managed_sandbox_sessions_max: '沙箱会话', + }, + }, cloudPlans: { subscriptionUnavailable: '暫不可訂閱', subscriptionUnavailableHint: diff --git a/web/tests/revisions/index.html b/web/tests/revisions/index.html new file mode 100644 index 000000000..ea3013314 --- /dev/null +++ b/web/tests/revisions/index.html @@ -0,0 +1 @@ +
diff --git a/web/tests/revisions/preview.tsx b/web/tests/revisions/preview.tsx new file mode 100644 index 000000000..c40db14b6 --- /dev/null +++ b/web/tests/revisions/preview.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import enUS from '../../src/i18n/locales/en-US'; +import CloudPlansDialog from '../../src/app/home/components/workspace-settings/CloudPlansDialog'; +import '../../src/app/global.css'; +const mode = new URLSearchParams(location.search).get('mode') || 'changed'; +const current = { + name: 'pro', + subscription_started_at: 1790812800, + revision: 1, + limits: { + 'bots.max': 2, + 'members.max': 3, + 'chat_history.retention_days': 30, + }, + entitlement_features: { mcp_stdio: false, 'platform.telegram': false }, + monthly_bonus_credits: 10, +}; +const i18n = createInstance(); +await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { translation: enUS } }, +}); +createRoot(document.getElementById('root')!).render( + + {}} + workspace={ + { + workspace: { uuid: 'fixture', name: 'Review workspace' }, + membership: { role: mode === 'member' ? 'viewer' : 'owner' }, + plan_name: 'pro', + current_plan: mode === 'unknown' ? null : current, + } as any + } + spaceURL="https://space.example.test" + /> + , +); diff --git a/web/tests/revisions/verify.mjs b/web/tests/revisions/verify.mjs new file mode 100644 index 000000000..367ac2963 --- /dev/null +++ b/web/tests/revisions/verify.mjs @@ -0,0 +1,72 @@ +import {createServer} from 'vite'; +import {chromium} from '@playwright/test'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import {mkdir,writeFile} from 'node:fs/promises'; +const workdir=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../../..'); +const evidence=path.join(workdir,'evidence'); +await mkdir(evidence,{recursive:true}); +const servers=[];let browser; +const results=[]; +const latest=(mode)=>({uuid:'latest',name:'pro',revision:mode==='same'?1:2,display_name:{en_US:'Pro'},description:{en_US:'Workspace plan'},price_cny:'70',price_usd:'10',price_jpy:'1500',features:[],limits: mode==='equal'||mode==='same'?{'bots.max':2,'members.max':3}:{'bots.max':8,'members.max':5},entitlement_features:{mcp_stdio:!['same','equal'].includes(mode)},disabled_adapters:['same','equal'].includes(mode)?['telegram']:[],chat_history_retention_days:['same','equal'].includes(mode)?30:90,monthly_bonus_credits:['same','equal'].includes(mode)?10:20,is_active:true,subscription_disabled:mode==='paused',sort_order:1,highlight:false}); +try { + for(const [repo,port] of [['space',4185],['core',4186]]){ + const server=await createServer({configFile:path.join(workdir,repo,'web/tests/revisions/vite.config.mjs')});await server.listen();servers.push(server); + } + browser=await chromium.launch({headless:true}); + for(const repo of ['space','core']){ + const modes=repo==='space'?['changed','same','equal','paused','removed','member','stripe','cancel-failure']:['changed','same','equal','paused','removed','member','unknown','error']; + for(const mode of modes){ + const page=await browser.newPage({viewport:{width:1360,height:1100}}); + page.setDefaultTimeout(120000); + const errors=[];page.on('pageerror',error=>{errors.push(error.message);console.error(error.message);}); + await page.route('**/*',async route=>{ + const url=new URL(route.request().url()); + if(url.hostname==='127.0.0.1')return route.continue(); + if(url.pathname==='/api/v1/cloud/v2/plans')return route.fulfill({status:mode==='error'?500:200,contentType:'application/json',body:JSON.stringify({code:0,data:mode==='removed'?[]:[latest(mode)]})}); + return route.abort(); + }); + const port=repo==='space'?4185:4186; + await page.goto(`http://127.0.0.1:${port}/tests/revisions/index.html?mode=${mode}${mode==='stripe'?'&intent=1':''}`,{waitUntil:'domcontentloaded',timeout:120000}); + if(repo==='core'&&mode==='error')await page.getByRole('alert').waitFor(); + else if(mode==='unknown')await page.getByText(/Current subscription details are unavailable/).waitFor(); + else await page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans] article').first().waitFor(); + if(errors.length)throw new Error(`${repo}/${mode}: ${errors.join(';')}`); + if(await page.locator('[data-revision-comparison]').count())throw new Error('Standalone comparison remains'); + if(['same','equal'].includes(mode) && await page.locator('[data-owned-plan]').count())throw new Error('Identical benefits duplicated'); + const area=page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans]'); + if(!['unknown','error'].includes(mode)) { + const copy=await area.innerText(); + if(/&#|revision|mcp stdio|workspace multi user|platform telegram/.test(copy))throw new Error('Internal metadata leaked into plan presentation'); + if(repo==='core'&&await area.locator('[data-changed], .bg-amber-500\\/10').count())throw new Error('Diff highlighting remains'); + if(['same','equal'].includes(mode)&&/Subscribed on|Latest quotas for this plan/.test(copy))throw new Error('Unnecessary comparison labels'); + } + if(mode==='changed') { + await area.getByText(/Subscribed on/).waitFor(); + await area.getByText('Latest quotas for this plan',{exact:true}).waitFor(); + const owned=page.locator('[data-owned-plan]'); + if(await owned.count()!==1)throw new Error('Missing integrated owned version'); + const horizontal=await owned.evaluate(el=>{const next=el.nextElementSibling;return !!next && Math.abs(next.getBoundingClientRect().y-el.getBoundingClientRect().y)<5 && next.getBoundingClientRect().x>el.getBoundingClientRect().x;}); + if(!horizontal)throw new Error('Owned and latest are not adjacent horizontally'); + } + if(repo==='space'&&mode==='stripe'){ + if(await page.getByRole('dialog').count())throw new Error('Deep link bypassed renewal gate'); + await page.getByRole('button',{name:'Manage subscription'}).first().click(); + await page.getByText('Server refresh confirmed cancellation').waitFor(); + if (!(await page.getByRole('dialog').count())) await page.getByRole('button',{name:/Upgrade to|Subscribe to|Choose/}).last().click(); + await page.getByRole('dialog').waitFor(); + } + if(repo==='space'&&mode==='cancel-failure'){ + await page.getByRole('button',{name:'Manage subscription'}).first().click(); + await page.getByText('Cancellation failed').waitFor(); + if(await page.getByRole('dialog').count())throw new Error('Failed cancellation opened checkout'); + } + if(mode==='member'&&repo==='core'&&await page.getByRole('link').count())throw new Error('Member purchase link'); + if(mode==='changed'||mode==='equal'||mode==='member'||mode==='paused')await page.screenshot({path:path.join(evidence,`${repo}-${mode}.png`),fullPage:true}); + results.push(`${repo}/${mode}: PASS`);console.log(results.at(-1));await page.close(); + } + } +} finally { + await writeFile(path.join(evidence,'browser-results.txt'),results.join('\n')+'\n'); + if(browser)await browser.close();for(const server of servers)await server.close(); +} diff --git a/web/tests/revisions/vite.config.mjs b/web/tests/revisions/vite.config.mjs new file mode 100644 index 000000000..390cf2604 --- /dev/null +++ b/web/tests/revisions/vite.config.mjs @@ -0,0 +1,5 @@ +import path from 'node:path'; +import tailwind from '@tailwindcss/postcss'; +import {fileURLToPath} from 'node:url'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..'); +export default {root,resolve:{alias:{'@':path.join(root,'src')}},esbuild:{jsx:'automatic'},css:{postcss:{plugins:[tailwind({base:root})]}},server:{host:'127.0.0.1',port:4186,strictPort:true}}; diff --git a/web/tests/unit/plan-revisions.test.mjs b/web/tests/unit/plan-revisions.test.mjs new file mode 100644 index 000000000..ebf985005 --- /dev/null +++ b/web/tests/unit/plan-revisions.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; +const m = { exports: {} }; +new Function('module', 'exports', ts.transpileModule(readFileSync(new URL('../../src/app/home/components/workspace-settings/plan-revisions.ts', import.meta.url), 'utf8'), {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022}}).outputText)(m, m.exports); +const { revisionRows } = m.exports; +const current = { name: 'pro', revision: 1, limits: {'bots.max': 2, 'chat_history.retention_days': 30}, monthly_bonus_credits: 10, entitlement_features: {'platform.telegram': false, mcp_stdio: false} }; +test('same version and revision-only changes do not duplicate comparisons', () => { + assert.ok(revisionRows(current, current).every(row => !row.changed)); + assert.ok(revisionRows(current, {...current, revision: 2}).every(row => !row.changed)); +}); +test('compares effective quotas, credits, retention, platforms and feature switches', () => { + const latest = {name:'pro', revision:2, limits:{'bots.max':5}, chat_history_retention_days:60, monthly_bonus_credits:20, entitlement_features:{mcp_stdio:true}, disabled_adapters:[]}; + const changed = revisionRows(current, latest).filter(row => row.changed); + assert.deepEqual(changed.map(row => row.key).sort(), ['feature:mcp_stdio','feature:platform.telegram','limit:bots.max','limit:chat_history.retention_days','monthly_credits'].sort()); + assert.equal(changed.find(row => row.key === 'feature:platform.telegram').after, true); +}); +test('catalog representations normalize to snapshot values', () => { + const latest = {name:'pro',revision:2,limits:{'bots.max':2},chat_history_retention_days:30,monthly_bonus_credits:10,disabled_adapters:['telegram']}; + assert.ok(revisionRows(current, latest).every(row => !row.changed)); +}); +test('missing latest keeps current values without invented changes', () => { + const rows=revisionRows(current); + assert.equal(rows.find(row=>row.key==='limit:bots.max').before,2); + assert.ok(rows.every(row=>!row.changed)); +});