diff --git a/browser_tests/fixtures/data/cloudWorkspace.ts b/browser_tests/fixtures/data/cloudWorkspace.ts index c175abed1ee..73b05ee86ab 100644 --- a/browser_tests/fixtures/data/cloudWorkspace.ts +++ b/browser_tests/fixtures/data/cloudWorkspace.ts @@ -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 = { @@ -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, diff --git a/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts b/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts index b21d2c47288..ceae63b091e 100644 --- a/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts +++ b/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts @@ -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 { @@ -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 { - 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 { + private async mockBoot( + members: Member[], + activeWorkspace: WorkspaceWithRole + ): Promise { const state: MemberMockState = { members: members.map((m) => ({ ...m })), patches: [] @@ -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) => { @@ -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] + }) ) ) diff --git a/browser_tests/tests/dialogs/memberRoleChange.spec.ts b/browser_tests/tests/dialogs/memberRoleChange.spec.ts index e17890ed310..6768031fe17 100644 --- a/browser_tests/tests/dialogs/memberRoleChange.spec.ts +++ b/browser_tests/tests/dialogs/memberRoleChange.spec.ts @@ -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 @@ -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 }) diff --git a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts index 9d0cd855f08..186bbb2a62f 100644 --- a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts +++ b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts @@ -25,7 +25,8 @@ const { mockOriginalOwnerId, mockFilteredMembers, mockFilteredPendingInvites, - mockIsPersonalWorkspace, + mockHasTeamPlan, + mockIsPlanLoading, mockIsOnTeamPlan, mockHasMultipleMembers, mockShowSearch, @@ -51,7 +52,8 @@ const { mockIsInviteDisabled: ref(false), mockFilteredMembers: ref([]), mockFilteredPendingInvites: ref([]), - mockIsPersonalWorkspace: ref(false), + mockHasTeamPlan: ref(true), + mockIsPlanLoading: ref(false), mockIsOnTeamPlan: ref(true), mockActiveView: ref<'active' | 'pending'>('active'), mockSearchQuery: ref(''), @@ -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, @@ -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, @@ -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 @@ -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 @@ -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' }), @@ -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', () => { @@ -431,6 +439,7 @@ describe('MembersPanelContent', () => { describe('not on team plan', () => { beforeEach(() => { + mockHasTeamPlan.value = false mockIsOnTeamPlan.value = false mockShowSearch.value = false mockShowViewTabs.value = false @@ -444,6 +453,7 @@ describe('MembersPanelContent', () => { }) it('hides the upsell banner when on a team plan', () => { + mockHasTeamPlan.value = true mockIsOnTeamPlan.value = true renderComponent() expect( @@ -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( @@ -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() }) }) diff --git a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue index cd7f1b9be0a..a94c5043cf1 100644 --- a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue +++ b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue @@ -8,7 +8,7 @@