Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,22 @@ const PAYMENT_METHOD_REQUIRED_RESPONSE = {
payment_method_url: 'https://pay.test/method'
} satisfies SubscribeResponse

const RETRIED_SUBSCRIPTION_OPERATION_ID = 'retried-subscription'
const RETRIED_SUBSCRIPTION_ACTION_URL =
'https://verify.example/retried-subscription'

const RETRIED_SUBSCRIPTION_RESPONSE = {
billing_op_id: RETRIED_SUBSCRIPTION_OPERATION_ID,
status: 'needs_payment_method',
payment_method_url: 'https://pay.test/retried-subscription'
} satisfies SubscribeResponse

const RETRIED_SUBSCRIPTION_OPERATION = {
id: RETRIED_SUBSCRIPTION_OPERATION_ID,
status: 'pending',
started_at: '2026-07-30T00:00:00Z'
} satisfies BillingOpStatusResponse

const UNEXPECTED_OPERATION_RESPONSE = {
id: PAYMENT_METHOD_REQUIRED_RESPONSE.billing_op_id,
status: 'failed',
Expand Down Expand Up @@ -574,6 +590,80 @@ test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
await expect(page).not.toHaveURL(/[?&](pricing|cycle)=/)
})

test('restores pending checkout when retrying a timed-out operation', async ({
page
}) => {
const subscribeRequests: Request[] = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Minor

What: This test declares its request trackers and performs its init script, cloud setup, and route registrations inside test().

Why this is an issue: The binding browser-test guidance requires mock setup and fixture arrangement in beforeEach or fixtures, with test bodies limited to act/assert. This couples stateful setup to the scenario and violates the repository’s required test structure.

Proposed Fix: Put this scenario in a nested test.describe and move the trackers and route setup into test.beforeEach or a custom fixture.

const operationPollRequests: Request[] = []
await page.addInitScript(() => {
window.open = () => window
})
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.route('**/api/billing/status', (route) =>
route.fulfill(jsonRoute(LEGACY_ACTIVE_STANDARD_STATUS))
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill(
jsonRoute({
plans: [CREATOR_ANNUAL_PLAN]
} satisfies BillingPlansResponse)
)
)
await page.route('**/api/billing/preview-subscribe', (route) =>
route.fulfill(jsonRoute(NEW_CREATOR_SUBSCRIPTION))
)
await page.route('**/api/billing/subscribe', (route) => {
subscribeRequests.push(route.request())
return route.fulfill(jsonRoute(RETRIED_SUBSCRIPTION_RESPONSE))
})
await page.route(
`**/api/billing/ops/${RETRIED_SUBSCRIPTION_OPERATION_ID}`,
(route) => {
operationPollRequests.push(route.request())
return route.fulfill(
jsonRoute({
...RETRIED_SUBSCRIPTION_OPERATION,
...(subscribeRequests.length > 1 && {
action_url: RETRIED_SUBSCRIPTION_ACTION_URL
})
} satisfies BillingOpStatusResponse)
)
}
)

await page.goto(`${APP_URL}/?pricing=creator&cycle=yearly`)

const subscribeButton = page.getByRole('button', {
name: 'Subscribe to Creator'
})
const backButton = page.getByRole('button', { name: 'Back', exact: true })
await cloudAppExpect(subscribeButton).toBeVisible()
await page.clock.install({ time: new Date('2026-07-30T00:00:00Z') })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: Minor

What: page.clock.install() runs after page.goto() and after the subscription UI renders.

Why this is an issue: Workspace boot has already used Date.now() and setTimeout() to schedule token refresh. Playwright documents this installation order as undefined behavior, so the timeout regression test has an avoidable reliability hole despite passing CI.

Proposed Fix: Move page.clock.install() immediately before page.goto(), after init scripts and route registration. Keep fastForward() where it is.

await subscribeButton.click()
await expect.poll(() => subscribeRequests.length).toBe(1)
await expect.poll(() => operationPollRequests.length).toBeGreaterThan(0)
await expect(backButton).toBeDisabled()

await page.clock.fastForward(5 * 60_000 + 1)

await expect(backButton).toBeEnabled()
await expect(
page.getByText('Subscription verification timed out', { exact: true })
).toBeVisible()
const pollCountAfterTimeout = operationPollRequests.length

await subscribeButton.click()

await expect.poll(() => subscribeRequests.length).toBe(2)
await expect(backButton).toBeDisabled()
await expect
.poll(() => operationPollRequests.length)
.toBeGreaterThan(pollCountAfterTimeout)
await expect(
page.getByRole('button', { name: 'Complete verification' })
).toBeVisible()
})

test('cleans orphaned pricing params without opening the table', async ({
page
}) => {
Expand Down
26 changes: 26 additions & 0 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,32 @@ describe('billingOperationStore', () => {
expect(store.getOperation('op-1')?.status).toBe('timeout')
})

it('restarts a subscription operation after a polling timeout', async () => {
let status: 'pending' | 'succeeded' = 'pending'
vi.mocked(workspaceApi.getBillingOpStatus).mockImplementation(
async () => ({
id: 'op-1',
status,
started_at: new Date().toISOString()
})
)

const store = useBillingOperationStore()
void store.startOperation('op-1', 'subscription')

await vi.advanceTimersByTimeAsync(5 * 60_000 + 8001)
expect(store.getOperation('op-1')?.status).toBe('timeout')

status = 'succeeded'
const retry = store.startOperation('op-1', 'subscription')

expect(store.getOperation('op-1')?.status).toBe('pending')
expect(store.isSettingUp).toBe(true)

await vi.advanceTimersByTimeAsync(0)
expect((await retry).status).toBe('succeeded')
})

it('allows five minutes to discover a subscription action', async () => {
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
Expand Down
3 changes: 2 additions & 1 deletion src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
initialActionUrl?: string
): Promise<BillingOperation> {
const existing = operations.value.get(opId)
if (existing) {
if (existing && existing.status !== 'timeout') {
return terminalPromises.get(opId) ?? Promise.resolve(existing)
}
if (existing) clearOperation(opId)

const actionUrl = validateActionUrl(initialActionUrl)
const operation: BillingOperation = {
Expand Down
Loading