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 <[email protected]>
This commit is contained in:
RockChinQ
2026-10-17 00:25:41 +08:00
co-authored by Claude Opus 5.5
parent ececcb8749
commit 16cfb6f8bd
22 changed files with 1054 additions and 144 deletions
+37
View File
@@ -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.
@@ -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,
}
)
+4
View File
@@ -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]:
+7
View File
@@ -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',
@@ -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 }) {
<button
type="button"
aria-label={label}
className="shrink-0 text-muted-foreground"
className="flex h-5 shrink-0 items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
>
<Info className="size-3.5" />
<Info className="size-3.5" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-80 whitespace-pre-line">
@@ -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: (
<>
<Layers className="size-4 shrink-0 text-blue-500" />
<span className="min-w-0 flex-1">{quotaLabel}</span>
<span className="min-w-0 break-words">{quotaLabel}</span>
<Help label={quotaLabel}>
{text(config?.tooltips?.quota_summary) && (
<p className="mb-2">{text(config?.tooltips?.quota_summary)}</p>
)}
<ul className="space-y-2">
{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 (
<li key={key}>
<div className="flex justify-between gap-5">
<span>{label}</span>
<span>
{limit < 0
? t('cloudPlans.unlimited')
: limit.toLocaleString()}
</span>
</div>
{text(config?.quotas?.[key]?.tooltip) && (
<p className="mt-1 opacity-75">
{text(config?.quotas?.[key]?.tooltip)}
</p>
)}
</li>
);
})}
{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 (
<li key={key}>
<div className="flex justify-between gap-5">
<span>{label}</span>
<span>
{limit < 0
? t('cloudPlans.unlimited')
: limit.toLocaleString()}
</span>
</div>
{text(config?.quotas?.[key]?.tooltip) && (
<p className="mt-1 opacity-75">
{text(config?.quotas?.[key]?.tooltip)}
</p>
)}
</li>
);
})}
</ul>
</Help>
</>
@@ -154,7 +167,7 @@ function Benefits({
retention: (
<>
<PlanIcon name="ri-history-line" />
<span className="min-w-0 flex-1">
<span className="min-w-0 break-words">
{t('cloudPlans.retention', {
count: plan.chat_history_retention_days,
})}
@@ -167,7 +180,7 @@ function Benefits({
monthly_credits: (
<>
<PlanIcon name="ri-coin-line" />
<span className="min-w-0 flex-1">
<span className="min-w-0 break-words">
{t('cloudPlans.credits', { count: plan.monthly_bonus_credits })}
</span>
<Help label={t('cloudPlans.creditsLabel')}>
@@ -179,7 +192,7 @@ function Benefits({
supported_platforms: (
<>
<PlanIcon name="ri-apps-2-line" />
<span className="min-w-0 flex-1">{t('cloudPlans.platforms')}</span>
<span className="min-w-0 break-words">{t('cloudPlans.platforms')}</span>
<Tooltip>
<TooltipTrigger
type="button"
@@ -234,7 +247,7 @@ function Benefits({
rows[`item:${index}`] = (
<>
<PlanIcon name={item.icon} />
<span className="min-w-0 flex-1 whitespace-pre-line">{label}</span>
<span className="min-w-0 whitespace-pre-line break-words">{label}</span>
{text(item.tooltip) && <Help label={label}>{text(item.tooltip)}</Help>}
</>
);
@@ -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<PublicCloudPlan | null>(
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({
</div>
</div>
<div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
{!loading && (
<>
{!workspace.current_plan && (
<p className="mb-4 text-sm" role="status">
{t('planRevision.unknown')}
</p>
)}
</>
)}
{loading ? (
<div
className="flex min-h-48 items-center justify-center"
@@ -363,7 +473,7 @@ export default function CloudPlansDialog({
{t('cloudPlans.retry')}
</Button>
</div>
) : !plans.length ? (
) : !entries.length ? (
<p className="py-10 text-center text-muted-foreground">
{t('cloudPlans.empty')}
</p>
@@ -371,120 +481,197 @@ export default function CloudPlansDialog({
<TooltipProvider delayDuration={200}>
<div
className={cn(
'grid gap-4 p-1 sm:grid-cols-2',
plans.length > 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 (
<article
key={plan.uuid}
className={cn(
'flex min-w-0 flex-col rounded-xl border p-5',
current && 'border-blue-500/50 bg-blue-500/5',
)}
aria-label={name}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-semibold">
<PlanIcon name={plan.display_config?.icon} />
{name}
</h3>
{current ? (
<Badge variant="secondary">
{t('cloudPlans.current')}
</Badge>
) : plan.highlight ? (
<Badge>{t('cloudPlans.recommended')}</Badge>
) : null}
</div>
<p className="mt-2 text-sm text-muted-foreground">
{localizedPlanText(plan.description, i18n.language)}
</p>
<p className="mt-5 text-2xl font-semibold">
{price === 0
? t('cloudPlans.free')
: formatPrice(price)}
{price > 0 && (
<span className="ml-1 text-xs font-normal text-muted-foreground">
/ {t(`cloudPlans.${cycle}`)}
</span>
{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 (
<article
key={plan.uuid}
data-owned-plan={ownedCard || undefined}
className={cn(
'flex min-w-0 flex-col rounded-xl border p-5',
current &&
!stacked &&
'border-blue-500/50 bg-blue-500/5',
)}
</p>
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
{cycle === 'annual' && price > 0
? t('cloudPlans.annualNote', {
price: formatPrice(price / 12),
})
: ''}
</p>
<div className="flex-1">
<Benefits plan={plan} spaceURL={spaceURL} />
</div>
{plan.subscription_disabled && (
<p className="mt-4 text-xs text-muted-foreground">
{t('cloudPlans.subscriptionUnavailableHint')}
</p>
)}
{plan.subscription_disabled && !current ? (
<Button
className="mt-6 w-full"
variant="outline"
disabled
>
{t('cloudPlans.subscriptionUnavailable')}
</Button>
) : downgradeBlocked ? null : !canManage ? (
<Button
className="mt-6 w-full"
variant="outline"
disabled
>
{t('cloudPlans.ownerOnly')}
</Button>
) : (
<Button
asChild
className="mt-6 w-full"
variant={plan.highlight ? 'default' : 'outline'}
>
<a
href={
current
? cloudSubscriptionManagementURL(
spaceURL,
workspace.workspace.uuid,
)
: cloudPlanCheckoutURL(
spaceURL,
workspace.workspace.uuid,
plan.name,
cycle,
currency,
)
}
target="_blank"
rel="noopener noreferrer"
>
{t(
current
? 'cloudPlans.manageSubscription'
: 'cloudPlans.choose',
aria-label={name}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-semibold">
<PlanIcon name={plan.display_config?.icon} />
{name}
</h3>
{current ? (
<Badge variant={stacked ? 'default' : 'secondary'}>
{t('cloudPlans.current')}
</Badge>
) : plan.highlight ? (
<Badge>{t('cloudPlans.recommended')}</Badge>
) : null}
</div>
<p className="mt-2 text-sm text-muted-foreground">
{localizedPlanText(plan.description, i18n.language)}
{distinct &&
!ownedCard &&
plan.name === owned?.name && (
<span className="block" data-latest-quota>
{t('planRevision.latestQuota')}
</span>
)}
<ArrowRight className="size-4" />
</a>
</Button>
)}
</article>
{ownedCard && subscribedOn && (
<span className="block" data-subscribed-on>
{subscribedOn}
</span>
)}
</p>
{ownedCard && !ownedCatalog ? (
<div className="mb-5" />
) : (
<>
<p className="mt-5 text-2xl font-semibold">
{price === 0
? t('cloudPlans.free')
: formatPrice(price)}
{price > 0 && (
<span className="ml-1 text-xs font-normal text-muted-foreground">
/ {t(`cloudPlans.${cycle}`)}
</span>
)}
</p>
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
{cycle === 'annual' && price > 0
? t('cloudPlans.annualNote', {
price: formatPrice(price / 12),
})
: ''}
</p>
</>
)}
<div className="flex-1">
<Benefits plan={plan} spaceURL={spaceURL} />
</div>
{plan.subscription_disabled && (
<p className="mt-4 text-xs text-muted-foreground">
{t('cloudPlans.subscriptionUnavailableHint')}
</p>
)}
{plan.subscription_disabled && !current ? (
<Button
className="mt-6 w-full"
variant="outline"
disabled
>
{t('cloudPlans.subscriptionUnavailable')}
</Button>
) : downgradeBlocked ? null : !canManage ? (
<Button
className="mt-6 w-full"
variant="outline"
disabled
>
{t('cloudPlans.ownerOnly')}
</Button>
) : distinct &&
!ownedCard &&
plan.name === owned?.name ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
className="mt-6 w-full"
variant={plan.highlight ? 'default' : 'outline'}
>
<a
href={cloudSubscriptionManagementURL(
spaceURL,
workspace.workspace.uuid,
)}
target="_blank"
rel="noopener noreferrer"
>
{t('cloudPlans.manageSubscription')}
<ArrowRight className="size-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('planRevision.cancelToSubscribe')}
</TooltipContent>
</Tooltip>
) : (
<Button
asChild
className="mt-6 w-full"
variant={plan.highlight ? 'default' : 'outline'}
>
<a
href={
workspace.plan_name &&
workspace.plan_name !== 'free'
? cloudSubscriptionManagementURL(
spaceURL,
workspace.workspace.uuid,
)
: cloudPlanCheckoutURL(
spaceURL,
workspace.workspace.uuid,
plan.name,
cycle,
currency,
)
}
target="_blank"
rel="noopener noreferrer"
>
{t(
workspace.plan_name &&
workspace.plan_name !== 'free'
? 'cloudPlans.manageSubscription'
: 'cloudPlans.choose',
)}
<ArrowRight className="size-4" />
</a>
</Button>
)}
{ownedCard && !latest && (
<p className="mt-4 text-sm">
{t('planRevision.unavailable')}
</p>
)}
</article>
);
};
if (plan === 'owned')
return ownedPlan && !latest
? renderCard(ownedPlan, true)
: null;
return ownedPlan && latest === plan ? (
<PlanRevisionStack
key={plan.uuid}
current={renderCard(ownedPlan, true)}
latest={renderCard(plan)}
/>
) : (
renderCard(plan)
);
})}
</div>
@@ -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<Layer>('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 (
<div
key={key}
data-revision-layer={key}
data-front={isFront || undefined}
aria-hidden={!isFront || undefined}
inert={!isFront}
className={cn(
'relative mr-8 flex min-w-0 origin-right rounded-xl bg-background transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:bg-background after:transition-opacity after:duration-500 [grid-area:stack] motion-reduce:transition-none [&>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}
</div>
);
};
return (
<div
className='relative grid min-w-0 [grid-template-areas:"stack"]'
data-plan-revision-stack
data-front-layer={front}
>
{layer('current', current)}
{layer('latest', latest)}
<button
type="button"
aria-label={label}
title={label}
data-revision-toggle
onClick={(event) => {
setFront(back);
// Pointer clicks should not leave the strip highlighted.
if (event.detail > 0) event.currentTarget.blur();
}}
className="group absolute inset-y-2 right-0 z-20 flex w-8 flex-col items-center justify-center gap-2 rounded-r-xl text-muted-foreground outline-none transition-colors hover:bg-primary/5 hover:text-primary focus-visible:bg-primary/10 focus-visible:text-primary"
>
<ChevronLeft
className="size-4 shrink-0 transition-transform group-hover:-translate-x-0.5"
aria-hidden
/>
<span
className="text-xs font-medium [writing-mode:vertical-rl]"
aria-hidden
>
{t(
back === 'latest'
? 'planRevision.latestTab'
: 'planRevision.currentTab',
)}
</span>
</button>
</div>
);
}
@@ -2,6 +2,7 @@ export type CloudPlanBillingCycle = 'monthly' | 'annual';
export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY';
export type PlanLocalizedText = Partial<Record<string, string>>;
export interface PublicCloudPlan {
entitlement_features?: Record<string, boolean>;
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<PublicCloudPlan> {
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;
}
@@ -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<string, number>;
entitlement_features?: Record<string, boolean>;
disabled_adapters?: string[];
chat_history_retention_days?: number;
monthly_bonus_credits?: number;
}
export function benefitValues(plan: PlanRevisionBenefits) {
const values: Record<string, number | boolean> = {};
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 };
});
}
+8
View File
@@ -24,6 +24,14 @@ export interface WorkspaceMembership {
}
export interface CurrentWorkspace {
current_plan?: {
subscription_started_at?: number | null;
name: string;
revision: number;
limits: Record<string, number>;
entitlement_features: Record<string, boolean>;
monthly_bonus_credits: number;
} | null;
workspace: Workspace;
membership: WorkspaceMembership;
permissions: string[];
+45
View File
@@ -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:
+45
View File
@@ -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:
+45
View File
@@ -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:
+45
View File
@@ -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:
+44
View File
@@ -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:
+45
View File
@@ -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:
+42
View File
@@ -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:
+42
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
<html><head><meta charset="utf-8" /></head><body><div id="root"></div><script type="module" src="./preview.tsx"></script></body></html>
+42
View File
@@ -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(
<I18nextProvider i18n={i18n}>
<CloudPlansDialog
open
onOpenChange={() => {}}
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"
/>
</I18nextProvider>,
);
+72
View File
@@ -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();
}
+5
View File
@@ -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}};
+27
View File
@@ -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));
});