[web] Add cancel-loss-messaging split test (#36363)

* [web] Add cancel-loss-messaging split test

Populate the cancel confirmation step with plan-specific losses when the
cancel-loss-messaging split test is enabled: "Are you sure you want to
cancel?" heading, the subscription end date, and a card listing what the
user loses moving to the Free plan (compile timeout, AI, collaborators,
history), with the lost benefit struck through next to its Free-plan
replacement.

Shown only to non-trial Student/Standard/Pro individual subscribers with
no other retention offer (trial extension or downgrade upsell) — those
keep the existing confirmation.

Copy matches the mockups in the ticket. No new analytics events; the
existing subscription-page-cancel-button-click / subscription-cancelled
funnel is used.

Part of #35765

* [web] Consolidate ActiveSubscription stories, add cancel confirmation story

Replace the 13 fixture-per-story exports with a single Dashboard story
driven by controls (subscription fixture select, flexible licensing,
cancel-loss-messaging variant), using the CSF factories pattern. Add a
CancelConfirmation story that renders the cancel confirmation step
directly so the cancel-loss-messaging variant can be previewed without
clicking through, with a Professional fixture available to see the Pro
loss rows.

* [web] Use withSplitTests for the ActiveSubscription stories

Register cancel-loss-messaging in defaultSplitTestsArgTypes and drive the
stories' variant from the shared withSplitTests decorator (radio control,
SplitTestContext provided from args) instead of hand-rolled meta tags and
SplitTestProvider. Generalize withSplitTests' typing so it composes with
CSF factories (preview.meta) as well as legacy meta objects.

* [web] Fix Storybook indexing of the ActiveSubscription stories

Storybook's static CSF indexer requires preview.meta() to receive an
object literal, so spread withSplitTests' argTypes/decorators into the
meta literal instead of wrapping the whole config in the helper call.

* [web] Compose ActiveSubscription stories from orthogonal controls

- Wrap the stories in #subscription-dashboard-root so the dashboard's
  scoped styles (e.g. the cancel-loss strikethrough) apply in Storybook
- Replace the single fixture dropdown with independent controls (group,
  plan, period, currency, trial, status, pending plan change, add-ons,
  coupons, pending license change), building the subscription from the
  fixtures' base + patches
- Guard impossible mixes with argTypes `if` conditions: individual-only
  controls hide when group is on, and pendingLicenseChange only shows
  for groups

* [web] Add student plan to the ActiveSubscription story controls

The cancel-loss-messaging variant has student-specific rows (AI
Assistant / 10 collaborators), so the plan control needs a student
option. Groups have no student plans, so for group subscriptions the
student choice maps to the Standard group fixture.

* [web] Provide ol-plans in the ActiveSubscription stories

The cancel confirmation's downgrade branch (monthly Standard is
isEligibleForDowngradeUpsell) looks up paid-personal in ol-plans and
spins forever when the meta tag is missing. Provide ol-plans and
ol-groupPlans from the fixtures, matching the test render helper.

GitOrigin-RevId: e8491c8d39ed059e42ea3442e8d70c3ef304b009
This commit is contained in:
Antoine Clausse
2026-09-17 11:52:02 +00:00
committed by Copybot
parent dac6254831
commit 1daed4efe4
7 changed files with 312 additions and 0 deletions
@@ -387,6 +387,86 @@ describe('<ActiveSubscription />', function () {
expect(hiddenText.getAttribute('aria-hidden')).to.equal('true')
})
describe('cancel-loss-messaging split test', function () {
const enabledVariant: MetaTag = {
name: 'ol-splitTestVariants',
value: { 'cancel-loss-messaging': 'enabled' },
}
it('shows the loss messaging for a Standard subscriber when enabled', function () {
renderActiveSubscription(annualActiveSubscription, [enabledVariant])
showConfirmCancelUI()
screen.getByRole('heading', {
name: 'Are you sure you want to cancel?',
})
screen.getByText(
`Cancelling will end your subscription on ${annualActiveSubscription.payment.nextPaymentDueDate}.`
)
screen.getByRole('heading', {
name: 'You’ll be moved to the Free plan',
})
screen.getByText('AI Assistant and higher AI allowance')
screen.getByText('10 collaborators per project')
screen.getByRole('button', { name: 'Keep subscription' })
screen.getByRole('button', { name: 'Cancel subscription' })
})
it('shows the Pro losses for a Professional subscriber', function () {
const proSubscription = cloneDeep(annualActiveSubscription)
proSubscription.planCode = 'professional-annual'
renderActiveSubscription(proSubscription, [enabledVariant])
showConfirmCancelUI()
screen.getByText('AI Assistant and max AI allowance')
screen.getByText('Unlimited collaborators per project')
})
it('cancels the subscription and redirects', async function () {
fetchMock.post(cancelSubscriptionUrl, { status: 200 })
renderActiveSubscription(annualActiveSubscription, [enabledVariant])
showConfirmCancelUI()
fireEvent.click(
screen.getByRole('button', { name: 'Cancel subscription' })
)
const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
sinon.assert.calledWithMatch(assignStub, '/user/subscription/canceled')
})
it('returns to the subscription dashboard on "Keep subscription"', function () {
renderActiveSubscription(annualActiveSubscription, [enabledVariant])
showConfirmCancelUI()
fireEvent.click(
screen.getByRole('button', { name: 'Keep subscription' })
)
expect(screen.queryByText('Are you sure you want to cancel?')).to.be
.null
})
it('keeps the default confirmation for a subscriber in trial', function () {
renderActiveSubscription(trialCollaboratorSubscription, [
enabledVariant,
])
showConfirmCancelUI()
screen.getByText('We’d love you to stay')
expect(screen.queryByText('Are you sure you want to cancel?')).to.be
.null
})
it('keeps the default confirmation when the split test is not enabled', function () {
renderActiveSubscription(annualActiveSubscription)
showConfirmCancelUI()
screen.getByText('We’d love you to stay')
expect(screen.queryByText('Are you sure you want to cancel?')).to.be
.null
})
})
describe('extend trial', function () {
const canExtend: MetaTag = {
name: 'ol-userCanExtendTrial',