From bc8fdbaff770392f708c44d99612d095c3814062 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 17 Jul 2026 11:59:59 -0400 Subject: [PATCH 1/4] test(ui): add AccountButton connected integration test --- .changeset/account-button-integration.md | 2 + .../account-button.integration.test.tsx | 256 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .changeset/account-button-integration.md create mode 100644 packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx diff --git a/.changeset/account-button-integration.md b/.changeset/account-button-integration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/account-button-integration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx new file mode 100644 index 00000000000..c312dfa918e --- /dev/null +++ b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx @@ -0,0 +1,256 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { AccountButton } from '../account-button'; + +// End-to-end wiring test for the connected AccountButton: it renders the real view through the real +// controller against a mocked Clerk, then drives the real popover DOM. Unlike the controller test +// (controller -> Clerk) and the view test (props -> DOM), this proves the three layers compose — +// including the container's close-on-success: one-shot actions close the popover, navigations leave +// it open. + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + imageUrl: string; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let isOrgLoaded: boolean; +let user: FakeUser | null; +let session: { id: string; checkAuthorization: ReturnType } | null; +let organization: { id: string } | null; +let membershipRequests: { count: number }; +let userMemberships: { data: ReturnType[]; count: number; revalidate: ReturnType }; +let userInvitations: { data: ReturnType[]; count: number; revalidate: ReturnType }; +let userSuggestions: { data: ReturnType[]; count: number; revalidate: ReturnType }; +let signedInSessions: FakeSession[]; + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: () => ({ isLoaded: isOrgLoaded, organization, membershipRequests }), + useOrganizationList: () => ({ userMemberships, userInvitations, userSuggestions }), + useClerk: () => ({ + navigate, + setActive, + signOut, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + client: { signedInSessions }, + __internal_environment: { + displayConfig: { + afterCreateOrganizationUrl: '/after-create', + afterSwitchSessionUrl: '/after-switch', + }, + }, + }), + }; +}); + +function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') { + return { + id, + status, + accept: vi.fn().mockResolvedValue(undefined), + publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, + }; +} + +function membership(orgId: string, name: string, membersCount: number) { + return { organization: { id: orgId, name, imageUrl: '', membersCount } }; +} + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + isOrgLoaded = true; + user = { + id: 'user_1', + firstName: 'Alice', + lastName: 'Smith', + username: 'alice', + primaryEmailAddress: { emailAddress: 'alice@example.com' }, + imageUrl: 'https://img/alice', + }; + session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) }; + organization = { id: 'org_1' }; + membershipRequests = { count: 4 }; + userMemberships = { + data: [membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], + count: 2, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + userInvitations = { + data: [acceptable('inv_1', 'org_3', 'Gamma')], + count: 1, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + userSuggestions = { + data: [acceptable('sug_1', 'org_2', 'Beta')], + count: 1, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + signedInSessions = [ + { id: 'sess_1', user: user }, + { + id: 'sess_2', + user: { + id: 'user_2', + firstName: 'Bob', + lastName: 'Jones', + username: null, + primaryEmailAddress: { emailAddress: 'bob@example.com' }, + imageUrl: 'https://img/bob', + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function renderAccountButton() { + return render( + + + , + ); +} + +const trigger = () => document.querySelector('[data-cl-slot="account-button-trigger"]'); +const popup = () => document.querySelector('[data-cl-slot="account-button-popup"]'); + +async function open() { + const user = userEvent.setup(); + await user.click(screen.getByRole('button')); + expect(popup()).toBeInTheDocument(); + return user; +} + +describe('AccountButton (connected)', () => { + it('renders nothing while the controller is loading', () => { + isUserLoaded = false; + renderAccountButton(); + expect(trigger()).toBeNull(); + }); + + it('renders nothing when there is no active user', () => { + user = null; + renderAccountButton(); + expect(trigger()).toBeNull(); + }); + + it('renders the trigger and keeps the popover closed until clicked', () => { + renderAccountButton(); + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('opens the popover on trigger click', async () => { + renderAccountButton(); + await open(); + expect(screen.getByRole('button', { name: /Other/ })).toBeInTheDocument(); + }); + + it('selecting an organization calls setActive and closes the popover', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: /Other/ })); + + expect(setActive).toHaveBeenCalledWith( + expect.objectContaining({ organization: 'org_9', redirectUrl: '/after-create' }), + ); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('selecting the personal workspace calls setActive with a null organization and closes', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: /Alice Smith/ })); + + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ organization: null })); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('switching to an additional account calls setActive with the session and closes', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: /Bob Jones/ })); + + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('signing out of a single session calls signOut with the session id and closes', async () => { + renderAccountButton(); + await open(); + + // The per-row "Sign out" is hover-revealed (pointer-events: none until row hover), a visual + // state jsdom can't apply, so dispatch the click directly rather than through userEvent. + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })); + + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1' }); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('signing out of all accounts calls signOut with no session and closes', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + + expect(signOut).toHaveBeenCalledWith(); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('accepting an invitation accepts it, revalidates, and closes', async () => { + renderAccountButton(); + const user = await open(); + const invitation = userInvitations.data[0]; + + await user.click(screen.getByRole('button', { name: 'Accept' })); + + await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1)); + expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + + it('navigating to manage the organization navigates and leaves the popover open', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(navigate).toHaveBeenCalledWith('/org-profile'); + expect(popup()).toBeInTheDocument(); + }); +}); From 088fc5a71e60e2cbd68b0496b09dd481255f0734 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 17 Jul 2026 12:06:10 -0400 Subject: [PATCH 2/4] test(ui): cover create-org, accept-suggestion, manage-account paths in AccountButton integration test --- .../account-button.integration.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx index c312dfa918e..b8e44279c98 100644 --- a/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx +++ b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx @@ -244,6 +244,18 @@ describe('AccountButton (connected)', () => { await waitFor(() => expect(popup()).not.toBeInTheDocument()); }); + it('accepting a suggestion accepts it, revalidates, and closes', async () => { + renderAccountButton(); + const user = await open(); + const suggestion = userSuggestions.data[0]; + + await user.click(screen.getByRole('button', { name: 'Join' })); + + await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1)); + expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).not.toBeInTheDocument()); + }); + it('navigating to manage the organization navigates and leaves the popover open', async () => { renderAccountButton(); const user = await open(); @@ -253,4 +265,25 @@ describe('AccountButton (connected)', () => { expect(navigate).toHaveBeenCalledWith('/org-profile'); expect(popup()).toBeInTheDocument(); }); + + it('navigating to manage the account (personal mode) navigates and leaves the popover open', async () => { + organization = null; + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Manage account' })); + + expect(navigate).toHaveBeenCalledWith('/user-profile'); + expect(popup()).toBeInTheDocument(); + }); + + it('creating an organization navigates and leaves the popover open', async () => { + renderAccountButton(); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Add organization' })); + + expect(navigate).toHaveBeenCalledWith('/create-org'); + expect(popup()).toBeInTheDocument(); + }); }); From bb61092d0e3ed4f74a8a8cbaf2551bf4c86d4441 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 20 Jul 2026 14:11:40 -0400 Subject: [PATCH 3/4] feat(ui): account for single-session mode and memberless invites in AccountButton Gate add-account and sign-out-of-all on singleSessionMode in the controller, and render pending invitations/suggestions even with no organization memberships. --- .../account-button.controller.test.tsx | 18 +++++++++++++++ .../account-button.integration.test.tsx | 23 +++++++++++++++++++ .../account/account-button.controller.tsx | 10 +++++--- .../mosaic/account/account-button.view.tsx | 4 +++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/account/__tests__/account-button.controller.test.tsx b/packages/ui/src/mosaic/account/__tests__/account-button.controller.test.tsx index 5e1b7ee3408..9247edb8e35 100644 --- a/packages/ui/src/mosaic/account/__tests__/account-button.controller.test.tsx +++ b/packages/ui/src/mosaic/account/__tests__/account-button.controller.test.tsx @@ -28,6 +28,7 @@ let userMemberships: { data: unknown[]; count: number; revalidate: ReturnType }; let userSuggestions: { data: unknown[]; count: number; revalidate: ReturnType }; let signedInSessions: FakeSession[]; +let singleSessionMode: boolean; let setActive: ReturnType; let signOut: ReturnType; @@ -52,6 +53,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { buildSignInUrl: () => '/sign-in', client: { signedInSessions }, __internal_environment: { + authConfig: { singleSessionMode }, displayConfig: { afterCreateOrganizationUrl: '/after-create', afterSwitchSessionUrl: '/after-switch', @@ -121,6 +123,7 @@ beforeEach(() => { setActive = vi.fn().mockResolvedValue(undefined); signOut = vi.fn().mockResolvedValue(undefined); navigate = vi.fn().mockResolvedValue(undefined); + singleSessionMode = false; }); afterEach(() => { @@ -142,6 +145,8 @@ function Harness() { {String(c.activeOrganizationId)} {String(c.hasOrganizations)} {c.additionalAccounts.map(a => a.userId).join(',')} + {String(Boolean(c.onAddAccount))} + {String(Boolean(c.onSignOutAll))} {JSON.stringify(c.memberships)} {JSON.stringify(c.suggestions)} {JSON.stringify(c.invitations)} @@ -363,6 +368,19 @@ describe('useAccountButtonController', () => { expect(navigate).toHaveBeenCalledWith('/create-org'); }); + it('offers add-account and sign-out-all in multi-session mode', () => { + render(); + expect(screen.getByTestId('can-add-account')).toHaveTextContent('true'); + expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('true'); + }); + + it('omits add-account and sign-out-all in single-session mode', () => { + singleSessionMode = true; + render(); + expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); + expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false'); + }); + it('accepts invitations and suggestions, then revalidates the collection', async () => { render(); diff --git a/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx index b8e44279c98..f42f41ce1cf 100644 --- a/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx +++ b/packages/ui/src/mosaic/account/__tests__/account-button.integration.test.tsx @@ -37,6 +37,7 @@ let userMemberships: { data: ReturnType[]; count: number; rev let userInvitations: { data: ReturnType[]; count: number; revalidate: ReturnType }; let userSuggestions: { data: ReturnType[]; count: number; revalidate: ReturnType }; let signedInSessions: FakeSession[]; +let singleSessionMode: boolean; let setActive: ReturnType; let signOut: ReturnType; @@ -60,6 +61,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { buildSignInUrl: () => '/sign-in', client: { signedInSessions }, __internal_environment: { + authConfig: { singleSessionMode }, displayConfig: { afterCreateOrganizationUrl: '/after-create', afterSwitchSessionUrl: '/after-switch', @@ -129,6 +131,7 @@ beforeEach(() => { setActive = vi.fn().mockResolvedValue(undefined); signOut = vi.fn().mockResolvedValue(undefined); navigate = vi.fn().mockResolvedValue(undefined); + singleSessionMode = false; }); afterEach(() => { @@ -256,6 +259,26 @@ describe('AccountButton (connected)', () => { await waitFor(() => expect(popup()).not.toBeInTheDocument()); }); + it('in single-session mode hides add-account and sign-out-of-all', async () => { + singleSessionMode = true; + signedInSessions = [{ id: 'sess_1', user: user as FakeUser }]; + renderAccountButton(); + await open(); + + expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + }); + + it('shows pending invitations and suggestions even with no organization memberships', async () => { + userMemberships = { data: [], count: 0, revalidate: vi.fn().mockResolvedValue(undefined) }; + organization = null; + renderAccountButton(); + await open(); + + expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument(); + }); + it('navigating to manage the organization navigates and leaves the popover open', async () => { renderAccountButton(); const user = await open(); diff --git a/packages/ui/src/mosaic/account/account-button.controller.tsx b/packages/ui/src/mosaic/account/account-button.controller.tsx index 500d4f240f7..d4e017abd6c 100644 --- a/packages/ui/src/mosaic/account/account-button.controller.tsx +++ b/packages/ui/src/mosaic/account/account-button.controller.tsx @@ -57,7 +57,9 @@ export function useAccountButtonController(): AccountButtonController { const clerk = useClerk(); const router = useMosaicRouter(); - const displayConfig = useMosaicEnvironment()?.displayConfig; + const environment = useMosaicEnvironment(); + const displayConfig = environment?.displayConfig; + const singleSessionMode = environment?.authConfig?.singleSessionMode ?? false; if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded) { return { status: 'loading' }; @@ -122,12 +124,14 @@ export function useAccountButtonController(): AccountButtonController { onSwitchAccount: sessionId => clerk.setActive({ session: sessionId, redirectUrl: displayConfig?.afterSwitchSessionUrl }), onSignOutSession: sessionId => clerk.signOut({ sessionId }), - onSignOutAll: () => clerk.signOut(), + // Single-session apps cannot hold a second session, so adding an account and signing out of + // "all accounts" are meaningless there; the per-session sign out on the active row remains. + onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut(), onManageAccount: () => router.navigate(clerk.buildUserProfileUrl()), onManageOrganization: () => router.navigate(clerk.buildOrganizationProfileUrl()), onManageMembers: () => router.navigate(clerk.buildOrganizationProfileUrl()), onCreateOrganization: () => router.navigate(clerk.buildCreateOrganizationUrl()), - onAddAccount: () => router.navigate(clerk.buildSignInUrl()), + onAddAccount: singleSessionMode ? undefined : () => router.navigate(clerk.buildSignInUrl()), onAcceptSuggestion: suggestionId => { const suggestion = suggestionData.find(s => s.id === suggestionId); return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); diff --git a/packages/ui/src/mosaic/account/account-button.view.tsx b/packages/ui/src/mosaic/account/account-button.view.tsx index 22a11c41943..b85c2b87c71 100644 --- a/packages/ui/src/mosaic/account/account-button.view.tsx +++ b/packages/ui/src/mosaic/account/account-button.view.tsx @@ -884,7 +884,9 @@ export function AccountButtonPopup() {
- {data.hasOrganizations ? : null} + {data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0 ? ( + + ) : null}