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
11 changes: 6 additions & 5 deletions browser_tests/fixtures/data/cloudWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
// `/api/features` is the remote-config source: production builds resolve the
// workspaces flag from it (the `ff:` localStorage override is dev-only).
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
team_workspaces_enabled: true
team_workspaces_enabled: true,
billing_control_enabled: true
}

export const TEAM_WORKSPACE: WorkspaceWithRole = {
Expand Down Expand Up @@ -67,21 +68,21 @@ export const DEFAULT_TEAM_MEMBERS: Member[] = [
MEMBER_JOHN
]

const TEAM_PLAN_SLUG = 'team-pro-monthly'

export const TEAM_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
plan_slug: TEAM_PLAN_SLUG,
billing_status: 'paid',
has_funds: true,
renewal_date: '2099-02-20T00:00:00Z'
}

// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
// the whole role-management UI.
export const TEAM_PRO_PLAN: Plan = {
slug: 'pro-monthly',
slug: TEAM_PLAN_SLUG,
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
Expand Down
30 changes: 20 additions & 10 deletions browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Page, Route } from '@playwright/test'

import type { Member } from '@/platform/workspace/api/workspaceApi'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'

import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import {
Expand Down Expand Up @@ -41,18 +44,22 @@ export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}

async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS
members: Member[] = DEFAULT_TEAM_MEMBERS,
activeWorkspace: WorkspaceWithRole = TEAM_WORKSPACE
): Promise<MemberMockState> {
const state = await this.mockBoot(members)
const state = await this.mockBoot(members, activeWorkspace)
await new CloudAuthHelper(this.page).mockAuth()
await this.page.addInitScript(() => {
await this.page.addInitScript((workspaceId) => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', workspaceId)
}, activeWorkspace.id)
return state
}

private async mockBoot(members: Member[]): Promise<MemberMockState> {
private async mockBoot(
members: Member[],
activeWorkspace: WorkspaceWithRole
): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
Expand Down Expand Up @@ -93,11 +100,11 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await mockWorkspaceTokenMint(page, activeWorkspace)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))

await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
r.fulfill(jsonRoute({ workspaces: [activeWorkspace] }))
)

await page.route('**/api/workspace/members**', (route: Route) => {
Expand Down Expand Up @@ -140,7 +147,10 @@ export class CloudWorkspaceMockHelper {
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
jsonRoute({
current_plan_slug: TEAM_PRO_PLAN.slug,
plans: [TEAM_PRO_PLAN]
})
)
)

Expand Down
35 changes: 35 additions & 0 deletions browser_tests/tests/dialogs/memberRoleChange.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import type { Member } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
CREATOR,
DEFAULT_TEAM_MEMBERS,
MEMBER_JANE,
MEMBER_JOHN,
VIEWER
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import { workspace } from '@e2e/fixtures/utils/workspaceMocks'

// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
Expand Down Expand Up @@ -70,6 +72,39 @@ async function openChangeRoleSubmenu(page: Page) {
).toBeVisible()
}

test.describe('Members plan gating', { tag: '@cloud' }, () => {
test('personal workspace with a Team plan gets member management', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup(
DEFAULT_TEAM_MEMBERS,
workspace('personal', 'owner')
)
const content = await openMembersTab(page)

const inviteButton = content.getByRole('button', {
name: 'Invite member'
})
await expect(inviteButton).toBeEnabled()
await expect(
content.getByRole('button', { name: 'Role', exact: true })
).toBeVisible()
await expect(
content.getByText(MEMBER_JANE.email, { exact: true })
).toBeVisible()
await expect(
content.getByRole('button', { name: 'Upgrade to Team' })
).toHaveCount(0)

await inviteButton.click()
await expect(
page.getByRole('heading', {
name: 'Invite members to this workspace'
})
).toBeVisible()
})
})

test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const {
mockOriginalOwnerId,
mockFilteredMembers,
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockHasTeamPlan,
mockIsPlanLoading,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
Expand All @@ -51,7 +52,8 @@ const {
mockIsInviteDisabled: ref(false),
mockFilteredMembers: ref<WorkspaceMember[]>([]),
mockFilteredPendingInvites: ref<PendingInvite[]>([]),
mockIsPersonalWorkspace: ref(false),
mockHasTeamPlan: ref(true),
mockIsPlanLoading: ref(false),
mockIsOnTeamPlan: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
Expand Down Expand Up @@ -86,7 +88,10 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
hasTeamPlan: mockHasTeamPlan,
isPlanLoading: mockIsPlanLoading,
isOnTeamPlan: mockIsOnTeamPlan,
hasLapsedTeamPlan: computed(() => false),
hasMultipleMembers: mockHasMultipleMembers,
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
Expand All @@ -111,7 +116,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
mockFilteredMembers.value.map((m) => [m.id, mockMemberMenuItems()])
)
),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
Expand Down Expand Up @@ -205,7 +209,8 @@ describe('MembersPanelContent', () => {
mockOriginalOwnerId.value = null
mockFilteredMembers.value = []
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockHasTeamPlan.value = true
mockIsPlanLoading.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
Expand Down Expand Up @@ -239,9 +244,9 @@ describe('MembersPanelContent', () => {
}
})

describe('personal workspace', () => {
describe('personal plan', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockHasTeamPlan.value = false
mockIsOnTeamPlan.value = false
mockHasMultipleMembers.value = false
mockShowSearch.value = false
Expand Down Expand Up @@ -275,7 +280,7 @@ describe('MembersPanelContent', () => {
})
})

describe('team workspace - member list', () => {
describe('Team plan member list', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
Expand All @@ -301,6 +306,9 @@ describe('MembersPanelContent', () => {
renderComponent()
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
expect(
screen.queryByText('workspacePanel.members.upsellBanner')
).toBeNull()
})

it('shows more options button for non-current members', () => {
Expand Down Expand Up @@ -431,6 +439,7 @@ describe('MembersPanelContent', () => {

describe('not on team plan', () => {
beforeEach(() => {
mockHasTeamPlan.value = false
mockIsOnTeamPlan.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
Expand All @@ -444,6 +453,7 @@ describe('MembersPanelContent', () => {
})

it('hides the upsell banner when on a team plan', () => {
mockHasTeamPlan.value = true
mockIsOnTeamPlan.value = true
renderComponent()
expect(
Expand All @@ -469,10 +479,18 @@ describe('MembersPanelContent', () => {
renderComponent()
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
})

it('does not show an upgrade banner while plan state is loading', () => {
mockIsPlanLoading.value = true
renderComponent()
expect(
screen.queryByText('workspacePanel.members.upsellBanner')
).toBeNull()
})
})

describe('contact us footer', () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
it('opens discord in a new tab on a Team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(
Expand All @@ -489,10 +507,9 @@ describe('MembersPanelContent', () => {
openSpy.mockRestore()
})

it('is hidden in personal workspaces', () => {
mockIsPersonalWorkspace.value = true
it('is shown whenever the active plan is Team', () => {
renderComponent()
expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull()
expect(screen.getByText('workspacePanel.members.contactUs')).toBeTruthy()
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
<template v-if="isOnTeamPlan">
{{
$t('workspacePanel.members.membersCount', {
count: members.length,
Expand Down Expand Up @@ -140,8 +140,7 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<template v-if="!hasTeamPlan">
<MemberListItem
:member="personalWorkspaceMember"
:is-current-user="true"
Expand All @@ -150,7 +149,6 @@
/>
</template>

<!-- Team Workspace: sorted list -->
<template v-else>
<MemberListItem
v-for="(member, index) in filteredMembers"
Expand Down Expand Up @@ -188,15 +186,12 @@
</div>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
v-if="!isPlanLoading && !isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex items-center pt-2"
>
<div v-if="isOnTeamPlan" class="flex items-center pt-2">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.needMoreMembers') }}
</p>
Expand Down Expand Up @@ -227,8 +222,10 @@ const {
searchQuery,
activeView,
maxSeats,
hasTeamPlan,
isOnTeamPlan,
hasLapsedTeamPlan,
isPlanLoading,
hasMultipleMembers,
showSearch,
showViewTabs,
Expand All @@ -240,7 +237,6 @@ const {
filteredMembers,
filteredPendingInvites,
memberMenus,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
Expand Down
Loading
Loading