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
13 changes: 13 additions & 0 deletions browser_tests/fixtures/data/cloudWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const TEAM_WORKSPACE: WorkspaceWithRole = {
subscription_tier: 'PRO'
}

export const TEAM_MEMBER_WORKSPACE: WorkspaceWithRole = {
...TEAM_WORKSPACE,
role: 'member'
}

export const CREATOR: Member = {
id: 'u-liz',
name: 'Liz',
Expand Down Expand Up @@ -99,6 +104,14 @@ export const ENDED_STANDARD_BILLING_STATUS = {
team_credit_stop: null
} satisfies IngestBillingStatusResponse & { billing_rail: 'stripe' }

export const INACTIVE_TEAM_BILLING_STATUS = {
...TEAM_BILLING_STATUS,
billing_status: 'inactive',
is_active: false,
subscription_status: 'canceled',
subscription_tier: 'TEAM'
} satisfies IngestBillingStatusResponse

export const TEAM_PRO_PLAN: Plan = {
slug: TEAM_PLAN_SLUG,
tier: 'PRO',
Expand Down
30 changes: 29 additions & 1 deletion browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page, Route } from '@playwright/test'
import type { Locator, Page, Route } from '@playwright/test'
import type { BillingStatusResponse } from '@comfyorg/ingest-types'

import type {
Expand All @@ -16,6 +16,7 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'

interface RoleChangeRequest {
Expand Down Expand Up @@ -45,6 +46,30 @@ const jsonRoute = (body: unknown) => ({
export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}

async openWorkspaceSettings(): Promise<Locator> {
await this.page.goto(
process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
)
await this.page.waitForFunction(
() => !!window.app?.extensionManager,
null,
{
timeout: 45_000
}
)
await this.page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = this.page.getByTestId(TestIds.dialogs.settings)
await dialog.waitFor({ state: 'visible' })
await dialog
.locator('nav')
.getByRole('button', { name: 'Workspace', exact: true })
.click()
return dialog.getByRole('main')
}

async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS,
activeWorkspace: WorkspaceWithRole = TEAM_WORKSPACE,
Expand Down Expand Up @@ -138,6 +163,9 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(billingStatus))
)
await page.route('**/api/billing/payment-portal', (r) =>
r.fulfill(jsonRoute({ url: 'https://billing.example/portal' }))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(
jsonRoute({
Expand Down
12 changes: 12 additions & 0 deletions browser_tests/fixtures/helpers/SubscriptionHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ export class SubscriptionHelper {
handler: checkoutHandler
})
await this.page.route(checkoutPattern, checkoutHandler)

const billingPortalPattern = '**/customers/billing'
const billingPortalHandler = async (route: Route) => {
await route.fulfill({
json: { billing_portal_url: 'https://billing.example/portal' }
})
}
this.routeHandlers.push({
pattern: billingPortalPattern,
handler: billingPortalHandler
})
await this.page.route(billingPortalPattern, billingPortalHandler)
}

configure(...operators: SubscriptionOperator[]): void {
Expand Down
86 changes: 84 additions & 2 deletions browser_tests/tests/dialogs/creditsTile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,27 @@ const mockBillingStatus: BillingStatusResponse = {
has_funds: true
}

async function mockCloudBoot(page: Page, billingControlEnabled = true) {
const freeBillingStatus: BillingStatusResponse = {
is_active: false,
subscription_tier: 'FREE',
has_funds: true
}

const endedPersonalBillingStatus: BillingStatusResponse = {
is_active: false,
subscription_status: 'ended',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
billing_status: 'inactive',
has_funds: true
}

async function mockCloudBoot(
page: Page,
billingControlEnabled = true,
billingStatus = mockBillingStatus
) {
// Frontend-origin boot endpoints (proxied to the backend in production).
// `/api/features` is the remote-config source: production builds resolve
// workspace availability and the billing UX rollout from it (the `ff:`
Expand Down Expand Up @@ -150,7 +170,10 @@ async function mockCloudBoot(page: Page, billingControlEnabled = true) {
// Workspace billing (flag-on path) — a personal workspace now routes through
// `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(mockBillingStatus))
r.fulfill(jsonRoute(billingStatus))
)
await page.route('**/api/billing/payment-portal', (r) =>
r.fulfill(jsonRoute({ url: 'https://billing.example/portal' }))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
Expand Down Expand Up @@ -211,6 +234,65 @@ async function openPlanAndCredits(page: Page) {
}

test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.open = (url) => {
document.documentElement.dataset.openedUrl = String(url)
return window
}
})
})

test('opens Billing & invoices for a paid owner without a duplicate invoice link', async ({
page
}) => {
test.setTimeout(60_000)

await mockCloudBoot(page)

const content = await openPlanAndCredits(page)
await expect(
content.getByRole('button', { name: 'Invoice history' })
).toHaveCount(0)
await content.getByRole('button', { name: 'Billing & invoices' }).click()

await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://billing.example/portal')
})

test('opens Billing & invoices for a Free owner without a subscription', async ({
page
}) => {
test.setTimeout(60_000)

await mockCloudBoot(page, true, freeBillingStatus)

const content = await openPlanAndCredits(page)
await expect(content.getByRole('heading', { name: 'Free' })).toBeVisible()
await content.getByRole('button', { name: 'Billing & invoices' }).click()

await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://billing.example/portal')
})

test('keeps Billing & invoices available after a Personal subscription ends', async ({
page
}) => {
test.setTimeout(60_000)

await mockCloudBoot(page, true, endedPersonalBillingStatus)

const content = await openPlanAndCredits(page)
await expect(content.getByText('Your subscription has ended')).toBeVisible()
await content.getByRole('button', { name: 'Billing & invoices' }).click()

await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://billing.example/portal')
})

test('keeps the legacy Workspace UX when billing controls are disabled', async ({
page
}) => {
Expand Down
102 changes: 81 additions & 21 deletions browser_tests/tests/dialogs/endedSubscription.spec.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,40 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'

import {
cloudAppFixture as test,
waitForCloudApp
} from '@e2e/fixtures/cloudAppFixture'
import { cloudAppFixture as test } from '@e2e/fixtures/cloudAppFixture'
import {
DEFAULT_TEAM_MEMBERS,
ENDED_STANDARD_BILLING_STATUS,
INACTIVE_TEAM_BILLING_STATUS,
TEAM_BILLING_STATUS,
TEAM_MEMBER_WORKSPACE,
TEAM_WORKSPACE
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { TestIds } from '@e2e/fixtures/selectors'

test.describe('Ended workspace subscription', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
let content: Locator

test.beforeEach(async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup(
await page.addInitScript(() => {
window.open = (url) => {
document.documentElement.dataset.openedUrl = String(url)
return window
}
})
const workspace = new CloudWorkspaceMockHelper(page)
await workspace.setup(
DEFAULT_TEAM_MEMBERS,
TEAM_WORKSPACE,
ENDED_STANDARD_BILLING_STATUS
)
await page.goto(process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188')
await waitForCloudApp(page)

await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId(TestIds.dialogs.settings)
await expect(dialog).toBeVisible()
await dialog
.locator('nav')
.getByRole('button', { name: 'Workspace', exact: true })
.click()
content = await workspace.openWorkspaceSettings()
})

test('shows subscribe prompt instead of stale paid plan metadata', async ({
page
}) => {
const content = page.getByTestId(TestIds.dialogs.settings).getByRole('main')

await expect(
content.getByRole('heading', {
name: 'This workspace is not on a subscription'
Expand All @@ -52,5 +46,71 @@ test.describe('Ended workspace subscription', { tag: '@cloud' }, () => {
await expect(
content.getByRole('heading', { name: 'Standard' })
).toHaveCount(0)
await expect(
content.getByRole('button', { name: 'Invoice history' })
).toHaveCount(0)

await content.getByRole('button', { name: 'Billing & invoices' }).click()
await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://billing.example/portal')
})
})

test.describe('Inactive Team subscription billing', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
let content: Locator

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.open = (url) => {
document.documentElement.dataset.openedUrl = String(url)
return window
}
})
const workspace = new CloudWorkspaceMockHelper(page)
await workspace.setup(
DEFAULT_TEAM_MEMBERS,
TEAM_WORKSPACE,
INACTIVE_TEAM_BILLING_STATUS
)
content = await workspace.openWorkspaceSettings()
})

test('keeps the owner billing portal available', async ({ page }) => {
await expect(
content.getByRole('heading', { name: 'Inactive team subscription' })
).toBeVisible()
await content.getByRole('button', { name: 'Billing & invoices' }).click()
await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://billing.example/portal')
})
})

test.describe('Team member billing permissions', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
let content: Locator

test.beforeEach(async ({ page }) => {
const workspace = new CloudWorkspaceMockHelper(page)
await workspace.setup(
DEFAULT_TEAM_MEMBERS,
TEAM_MEMBER_WORKSPACE,
TEAM_BILLING_STATUS
)
content = await workspace.openWorkspaceSettings()
})

test('does not expose owner billing actions', async () => {
Comment thread
dante01yoon marked this conversation as resolved.
await expect(
content.getByRole('heading', { name: 'Team', exact: true })
).toBeVisible()
await expect(
content.getByRole('button', { name: 'Billing & invoices' })
).toHaveCount(0)
await expect(
content.getByRole('button', { name: 'Invoice history' })
).toHaveCount(0)
})
})
14 changes: 14 additions & 0 deletions browser_tests/tests/localCreditsNoSubscribeUi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ test.describe('Local credits surfaces hide subscribe UI (non-cloud)', () => {
await expect(
creditsContent.getByText('Upgrade to add credits')
).toHaveCount(0)
await expect(
creditsContent.getByRole('button', { name: 'Manage subscription' })
).toHaveCount(0)
await expect(
creditsContent.getByRole('button', { name: 'Billing & invoices' })
).toHaveCount(0)
await expect(
creditsContent.getByRole('button', { name: 'Invoice history' })
).toBeVisible()
const invoiceRequest = page.waitForRequest('**/customers/billing')
await creditsContent
.getByRole('button', { name: 'Invoice history' })
.click()
expect((await invoiceRequest).method()).toBe('POST')

const settingsAddCredits = creditsContent.getByRole('button', {
name: 'Add credits'
Expand Down
Loading
Loading