From 32d1c011b2f74577f486720e8901590c6173f402 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 14:11:20 -0400 Subject: [PATCH 1/4] test(mosaic): cover the user button with feature tests in place of per-layer tests --- .../mosaic-user-button-feature-tests.md | 2 + .../__tests__/user-button.feature.test.tsx | 625 +++++++++- .../__tests__/user-button.model.test.tsx | 922 -------------- .../__tests__/user-button.view.test.tsx | 1068 ----------------- 4 files changed, 609 insertions(+), 2008 deletions(-) create mode 100644 .changeset/mosaic-user-button-feature-tests.md delete mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx delete mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx diff --git a/.changeset/mosaic-user-button-feature-tests.md b/.changeset/mosaic-user-button-feature-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-feature-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index a3158311600..f731379b977 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1,4 +1,5 @@ -import type { CustomPage } from '@clerk/shared/types'; +import { UNSAFE_PortalProvider as PortalProvider } from '@clerk/shared/react'; +import type { CustomPage, PhoneNumberJSON, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -68,10 +69,13 @@ function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = sign return renderWithClerk(tree(props)).then(view => ({ ...view, fapi })); } +type User = ReturnType; + const host = () => screen.getByTestId('host'); const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); const popup = () => screen.queryByRole('dialog', { name: 'Account' }); -const spinner = () => popup()?.querySelector('.cl-spinner') ?? null; +const waiting = () => + Array.from(popup()?.querySelectorAll('button') ?? []).some(button => button.getAttribute('aria-disabled') === 'true'); const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' }); async function open() { @@ -81,12 +85,12 @@ async function open() { return user; } -async function accountAction(user: ReturnType, label: string) { +async function accountAction(user: User, label: string) { await user.click(accountMenu()); await user.click(await screen.findByRole('menuitem', { name: label })); } -async function openAccounts(user: ReturnType) { +async function openAccounts(user: User) { await user.click(screen.getByRole('button', { name: 'Switch account' })); return screen.findByRole('menu'); } @@ -99,6 +103,26 @@ function requiredPopup() { return surface; } +const reading = (...names: string[]) => + within(requiredPopup()) + .queryAllByText(new RegExp(`^(${names.join('|')})$`)) + .map(node => node.textContent); + +const current = () => + Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent); + +const alone = () => signedIn({ client: fapiClient([aliceSession]) }); + +const noOffers = (overrides = {}) => signedIn({ invitations: [], suggestions: [], ...overrides }); + +const personalSession = fapiSession({ id: 'sess_1', user: alice }); + +async function openWithList() { + const user = await open(); + await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); + return user; +} + describe('UserButton', () => { describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => { it('renders nothing while Clerk is still loading', async () => { @@ -226,7 +250,7 @@ describe('UserButton', () => { await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); await waitFor(() => expect(trigger()).toHaveAccessibleName('Open account menu for Bob Jones')); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); @@ -302,7 +326,7 @@ describe('UserButton', () => { await user.click(await screen.findByRole('button', { name: 'Accept' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); @@ -316,10 +340,10 @@ describe('UserButton', () => { await waitFor(() => expect(reload.requests).toHaveLength(1)); await new Promise(resolve => setTimeout(resolve, 450)); - expect(spinner()).toBeInTheDocument(); + expect(waiting()).toBe(true); reload.release(); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); @@ -330,7 +354,7 @@ describe('UserButton', () => { await user.click(await screen.findByRole('button', { name: 'Join' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Join' })).toBeNull()); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); @@ -347,7 +371,7 @@ describe('UserButton', () => { expect(within(button).getByRole('progressbar')).toBeInTheDocument(); join.release(); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); }); @@ -365,6 +389,18 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); + it('hands the opened profile the portal root the app provides', async () => { + const container = document.createElement('div'); + serveFapi(signedIn()); + const { clerk } = await renderWithClerk( container}>{tree()}); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Manage account'); + + expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container); + }); + it('runs a custom menu item and closes', async () => { const onClick = vi.fn(); await renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] }); @@ -452,6 +488,52 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); + it('navigates to manage the account when a URL routes it', async () => { + const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const user = await open(); + + await accountAction(user, 'Manage account'); + + expect(navigate).toHaveBeenCalledWith('/account'); + expect(openUserProfile).not.toHaveBeenCalled(); + }); + + it('navigates to manage the organization when a URL routes it', async () => { + const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(navigate).toHaveBeenCalledWith('/org'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + it('sends add-account to the sign-in page', async () => { + const { navigate } = await renderUserButton({ signInUrl: '/sign-in' }); + const user = await open(); + + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'Add account' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/sign-in')); + }); + + it('sends a switched account with a pending task to finish it', async () => { + const pendingBob = fapiSession({ ...bobSession, status: 'pending', tasks: [{ key: 'choose-organization' }] }); + const { navigate } = await renderUserButton( + { signInUrl: '/sign-in' }, + signedIn({ client: fapiClient([aliceSession, pendingBob]) }), + ); + const user = await open(); + + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/sign-in#/tasks/choose-organization')); + }); + it('leaves create-organization out for a user who cannot create one', async () => { const restricted = fapiUser({ ...alice, create_organization_enabled: false }); await renderUserButton( @@ -474,7 +556,7 @@ describe('UserButton', () => { await user.click(screen.getByRole('button', { name: 'Other' })); - expect(spinner()).toBeInTheDocument(); + expect(waiting()).toBe(true); expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true'); expect(popup()).toBeInTheDocument(); @@ -514,11 +596,11 @@ describe('UserButton', () => { const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); await user.click(screen.getByRole('button', { name: 'Other' })); - expect(spinner()).toBeInTheDocument(); + expect(waiting()).toBe(true); touch.fail(); - await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 }); + await waitFor(() => expect(waiting()).toBe(false), { timeout: 2000 }); expect(popup()).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); }); @@ -531,7 +613,7 @@ describe('UserButton', () => { const menu = await openAccounts(user); await user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })); - expect(spinner()).toBeInTheDocument(); + expect(waiting()).toBe(true); await user.click(trigger()); await waitFor(() => expect(popup()).toBeNull()); @@ -540,13 +622,13 @@ describe('UserButton', () => { await user.click(trigger()); expect(popup()).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Switch account' }).querySelector('.cl-spinner')).not.toBeNull(); + expect(waiting()).toBe(true); await user.click(screen.getByRole('button', { name: 'Other' })); expect(touch.requests).toHaveLength(1); touch.release(); - await waitFor(() => expect(spinner()).toBeNull()); + await waitFor(() => expect(waiting()).toBe(false)); expect(popup()).toBeInTheDocument(); }); @@ -561,7 +643,7 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); await user.click(trigger()); - expect(spinner()).toBeInTheDocument(); + expect(waiting()).toBe(true); touch.release(); await waitFor(() => expect(popup()).toBeNull()); @@ -576,7 +658,7 @@ describe('UserButton', () => { await user.click(trigger()); - expect(spinner()).toBeNull(); + expect(waiting()).toBe(false); expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); }); }); @@ -591,4 +673,511 @@ describe('UserButton', () => { expect(await screen.findByRole('button', { name: 'Org 15' })).toBeInTheDocument(); }); + + describe('in user mode', () => { + it('heads the surface with the account, never the active organization', async () => { + await renderUserButton({ mode: 'user' }); + await open(); + + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(requiredPopup()).getByText('alice')).toBeInTheDocument(); + expect(within(requiredPopup()).queryByText('Acme')).toBeNull(); + }); + + it('drops the identifier line when it would only repeat the name', async () => { + const nameless = fapiUser({ ...alice, first_name: null, last_name: null }); + await renderUserButton( + { mode: 'user' }, + signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless }), bobSession]) }), + ); + await open(); + + expect(within(requiredPopup()).getAllByText('alice')).toHaveLength(1); + }); + + it('lists no workspaces and offers no way to make one', async () => { + await renderUserButton({ mode: 'user' }); + await open(); + + expect(screen.queryByText('Personal account')).toBeNull(); + expect(screen.queryByText('Gamma')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); + }); + + it('signs out from the header, beside the settings', async () => { + await renderUserButton({ mode: 'user' }); + await open(); + + expect(within(requiredPopup()).getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + }); + + it('lists every account in the flyout, then the way to add one', async () => { + await renderUserButton({ mode: 'user' }); + const user = await open(); + + const items = within(await openAccounts(user)).getAllByRole('menuitem'); + + expect(items.map(item => item.textContent)).toEqual([ + expect.stringContaining('alice'), + expect.stringContaining('bob@example.com'), + 'Add account', + ]); + expect(reading('Switch account', 'Add account', 'Sign out of all accounts')).toEqual([ + 'Switch account', + 'Sign out of all accounts', + ]); + }); + }); + + describe('in organization mode', () => { + it('heads the surface with the active organization and what can be done to it', async () => { + await renderUserButton({ mode: 'organization' }); + await open(); + + expect(within(requiredPopup()).getByText('Acme')).toBeInTheDocument(); + expect(within(requiredPopup()).getByText('3 members')).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('falls back to the account where no organization is active', async () => { + await renderUserButton({ mode: 'organization' }, signedIn({ client: fapiClient([personalSession]) })); + await open(); + + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + }); + + it('names no organization selected where personal is hidden and none is active', async () => { + await renderUserButton( + { mode: 'organization', hidePersonal: true }, + signedIn({ client: fapiClient([personalSession]) }), + ); + + expect(trigger()).toHaveAccessibleName(/No organization selected/); + await open(); + + expect(within(requiredPopup()).getByText('No organization selected')).toBeInTheDocument(); + expect(within(requiredPopup()).queryByText('Alice Smith')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); + }); + + it('carries no account rows, and trails the workspaces with create-organization', async () => { + await renderUserButton({ mode: 'organization' }, noOffers()); + await openWithList(); + + expect(reading('Personal account', 'Acme', 'Other', 'Create organization')).toEqual([ + 'Acme', + 'Personal account', + 'Acme', + 'Other', + 'Create organization', + ]); + for (const name of [ + 'Switch account', + 'Add account', + 'Sign out', + 'Sign out of all accounts', + 'Actions for alice', + ]) { + expect(screen.queryByRole('button', { name })).toBeNull(); + } + }); + }); + + describe('in combined mode', () => { + it('heads the surface with the account where the user takes priority, managing its active organization', async () => { + const { clerk } = await renderUserButton({ modePriority: 'user' }); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + + expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); + const user = await open(); + + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); + await user.click(within(requiredPopup()).getByRole('button', { name: 'Settings' })); + expect(openOrganizationProfile).toHaveBeenCalled(); + }); + + it('keeps sign-out off the header, since the account row carries it', async () => { + await renderUserButton(); + await open(); + + expect(within(requiredPopup()).queryByRole('button', { name: 'Sign out' })).toBeNull(); + }); + + it('trails the workspaces with create-organization', async () => { + await renderUserButton({}, noOffers()); + await openWithList(); + + expect(reading('Personal account', 'Acme', 'Other', 'Create organization').at(-1)).toBe('Create organization'); + }); + + it('marks the account it is already on in the flyout', async () => { + await renderUserButton(); + const user = await open(); + + const menu = await openAccounts(user); + + expect(within(menu).getByRole('menuitem', { name: 'alice' })).toHaveAttribute('aria-current', 'true'); + expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).not.toHaveAttribute('aria-current'); + expect(within(menu).getByRole('menuitem', { name: 'Add account' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); + }); + + it('takes add-account at the foot where there is no second account', async () => { + await renderUserButton({}, alone()); + await open(); + + expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + expect(reading('Switch account', 'Add account', 'Sign out of all accounts')).toEqual(['Add account']); + }); + + it('keeps the account row for an account with no organizations', async () => { + const loner = fapiUser({ ...alice, organization_memberships: [] }); + await renderUserButton( + {}, + noOffers({ memberships: [], client: fapiClient([fapiSession({ id: 'sess_1', user: loner }), bobSession]) }), + ); + await open(); + + expect(screen.getByRole('button', { name: 'Actions for alice' })).toBeInTheDocument(); + expect(screen.queryByText('Personal account')).toBeNull(); + }); + }); + + describe('naming the account', () => { + const phone: PhoneNumberJSON = { + object: 'phone_number', + id: 'idn_phone', + phone_number: '+15550100', + reserved_for_second_factor: false, + default_second_factor: false, + linked_to: [], + verification: null, + }; + const wallet: Web3WalletJSON = { + object: 'web3_wallet', + id: 'idn_wallet', + web3_wallet: '0xabc', + verification: null, + }; + const noEmail = { username: null, email_addresses: [], primary_email_address_id: null }; + + const signedInAs = (overrides: Partial) => + signedIn({ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ ...alice, ...overrides }) })]) }); + + it.each([ + ['first and last name', {}, 'Alice Smith'], + ['username', { first_name: null, last_name: null }, 'alice'], + ['email address', { first_name: null, last_name: null, username: null }, 'alice@example.com'], + ])('names it by its %s', async (_source, overrides, name) => { + await renderUserButton({ mode: 'user' }, signedInAs(overrides)); + + expect(trigger()).toHaveAccessibleName(`Open account menu for ${name}`); + }); + + it.each([ + ['username', {}, 'alice'], + ['email address', { username: null }, 'alice@example.com'], + ['phone number', { ...noEmail, phone_numbers: [phone], primary_phone_number_id: 'idn_phone' }, '+15550100'], + ['web3 wallet', { ...noEmail, web3_wallets: [wallet], primary_web3_wallet_id: 'idn_wallet' }, '0xabc'], + ])('identifies it by its %s', async (_source, overrides, identifier) => { + await renderUserButton({}, signedInAs(overrides)); + await open(); + + expect(screen.getByRole('button', { name: `Actions for ${identifier}` })).toBeInTheDocument(); + }); + }); + + describe('the workspace list', () => { + it('lists the workspaces held, then the invitations, then the suggestions', async () => { + await renderUserButton(); + await openWithList(); + + expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([ + 'Acme', + 'Personal account', + 'Acme', + 'Other', + 'Gamma', + 'Beta', + ]); + }); + + it('names the active workspace as the current one', async () => { + await renderUserButton(); + await openWithList(); + + expect(current()).toEqual([expect.stringContaining('Acme')]); + }); + + it('checks the personal workspace, and offers no switch to it, where it is active', async () => { + await renderUserButton({}, signedIn({ client: fapiClient([personalSession, bobSession]) })); + await openWithList(); + + expect(current()).toEqual([expect.stringContaining('Personal account')]); + expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull(); + }); + + it('describes each offer by the workspace it acts on', async () => { + await renderUserButton(); + await openWithList(); + + expect(screen.getByRole('button', { name: 'Accept' })).toHaveAccessibleDescription('Gamma'); + expect(screen.getByRole('button', { name: 'Join' })).toHaveAccessibleDescription('Beta'); + }); + + it('lists an invitation for an account that holds nothing else', async () => { + const loner = fapiUser({ ...alice, organization_memberships: [] }); + await renderUserButton( + {}, + signedIn({ + memberships: [], + suggestions: [], + client: fapiClient([fapiSession({ id: 'sess_1', user: loner })]), + }), + ); + await open(); + + expect(await screen.findByRole('button', { name: 'Accept' })).toBeInTheDocument(); + expect(reading('Gamma', 'Personal account')).toEqual(['Personal account', 'Gamma']); + }); + + it('drops revoked and expired invitations, which have nothing to accept', async () => { + await renderUserButton( + {}, + noOffers({ + invitations: [ + fapiInvitation('inv_1', gamma, { status: 'revoked' }), + fapiInvitation('inv_2', beta, { status: 'expired' }), + ], + }), + ); + await openWithList(); + + expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([ + 'Acme', + 'Personal account', + 'Acme', + 'Other', + ]); + }); + + it('reports an accepted suggestion instead of offering to join it again', async () => { + await renderUserButton({}, noOffers({ suggestions: [fapiSuggestion('sug_1', beta, { status: 'accepted' })] })); + await openWithList(); + + expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); + expect(reading('Beta', 'Requested')).toEqual(['Beta', 'Requested']); + }); + + it('lists an accepted invitation once, as a workspace to switch to', async () => { + await renderUserButton({}, noOffers({ invitations: [fapiInvitation('inv_1', gamma)] })); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Accept' })); + + expect(await screen.findByRole('button', { name: 'Gamma' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull(); + expect(reading('Gamma')).toEqual(['Gamma']); + }); + + it('holds every row behind one placeholder until the first page lands', async () => { + serveFapi(signedIn()); + const invitations = holdRequests('get', '/v1/me/organization_invitations'); + await renderWithClerk(tree()); + await open(); + + expect(await screen.findByText('Loading organizations…')).toBeInTheDocument(); + expect(reading('Personal account', 'Other')).toEqual([]); + expect(screen.getByRole('button', { name: 'Actions for alice' })).toBeInTheDocument(); + + invitations.release(); + await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); + expect(reading('Personal account', 'Other')).toEqual(['Personal account', 'Other']); + }); + }); + + describe('the foot', () => { + const terms = { id: 'terms', label: 'Terms of service', onClick: () => {} }; + const support = { id: 'support', label: 'Support', href: '/support' }; + + it('leads with the custom rows', async () => { + await renderUserButton({ customMenuItems: [terms, support] }); + await open(); + + expect( + reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts'), + ).toEqual(['Terms of service', 'Support', 'Switch account', 'Sign out of all accounts']); + expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); + }); + + it('renders the icon a custom row brings', async () => { + await renderUserButton({ customMenuItems: [{ ...terms, icon: }] }); + await open(); + + expect(screen.getByTestId('glyph')).toBeInTheDocument(); + }); + + it('orders the rows by the ids it is given, dropping ids no row answers to', async () => { + await renderUserButton({ + customMenuItems: [terms, support], + menuItemOrder: ['signOutAll', 'manageAccount', 'support', 'nonsense'], + }); + await open(); + + expect( + reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts'), + ).toEqual(['Sign out of all accounts', 'Support', 'Terms of service', 'Switch account']); + }); + + it.each([ + ['Switch account', signedIn()], + ['Add account', alone()], + ])('orders the accounts slot ahead of a custom row as "%s"', async (label, seed) => { + await renderUserButton({ customMenuItems: [terms], menuItemOrder: ['switchAccount', 'addAccount'] }, seed); + await open(); + + expect( + reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts')[0], + ).toBe(label); + }); + + it('carries the custom rows on an organization surface too', async () => { + await renderUserButton({ mode: 'organization', customMenuItems: [terms] }); + await open(); + + expect( + reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts'), + ).toEqual(['Terms of service']); + }); + + it('signs the popup with Clerk where the instance is branded', async () => { + await renderUserButton({}, signedIn({ environment: fapiEnvironment({ display_config: { branded: true } }) })); + await open(); + + expect(within(requiredPopup()).getByRole('link', { name: 'Clerk' })).toBeInTheDocument(); + }); + + it('carries no mark where the instance is unbranded', async () => { + await renderUserButton(); + await open(); + + expect(within(requiredPopup()).queryByRole('link', { name: 'Clerk' })).toBeNull(); + }); + }); + + describe('while an action is in flight', () => { + it.each(['Other', 'Personal account', 'Sign out of all accounts'])( + 'holds "%s" in place, aria-disabled and still focusable', + async label => { + await renderUserButton({}, noOffers()); + const user = await openWithList(); + const before = screen.getByRole('button', { name: label }); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await openAccounts(user).then(menu => + user.click(within(menu).getByRole('menuitem', { name: 'bob@example.com' })), + ); + + const after = screen.getByRole('button', { name: label }); + expect(after).toBe(before); + expect(after).toHaveAttribute('aria-disabled', 'true'); + expect(after).toBeEnabled(); + + touch.release(); + await waitFor(() => expect(waiting()).toBe(false)); + }, + ); + + it('reports the switch on the row that owns it, and nothing on the rows beside it', async () => { + await renderUserButton({}, noOffers()); + const user = await openWithList(); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await user.click(screen.getByRole('button', { name: 'Other' })); + + const busy = screen.getByRole('button', { name: 'Other' }); + expect(busy).toHaveAttribute('aria-busy', 'true'); + expect(within(busy).getByRole('progressbar')).toHaveAccessibleName('pending'); + const idle = screen.getByRole('button', { name: 'Personal account' }); + expect(idle).not.toHaveAttribute('aria-busy'); + expect(within(idle).queryByRole('progressbar')).toBeNull(); + expect(screen.getByRole('button', { name: 'Actions for alice' })).toHaveAttribute('aria-disabled', 'true'); + + touch.release(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('stands a custom action down but leaves a custom link followable', async () => { + const onClick = vi.fn(); + await renderUserButton( + { + customMenuItems: [ + { id: 'terms', label: 'Terms of service', onClick }, + { id: 'support', label: 'Support', href: '/support' }, + ], + }, + noOffers(), + ); + const user = await openWithList(); + const touch = holdRequests('post', '/v1/client/sessions/:id/touch'); + + await user.click(screen.getByRole('button', { name: 'Other' })); + + const action = screen.getByRole('button', { name: 'Terms of service' }); + expect(action).toHaveAttribute('aria-disabled', 'true'); + await user.click(action); + expect(onClick).not.toHaveBeenCalled(); + expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); + + touch.release(); + await waitFor(() => expect(popup()).toBeNull()); + }); + }); + + describe('the trigger', () => { + it.each([ + [{ mode: 'organization' as const }, 'Acme'], + [{ mode: 'combined' as const }, 'Acme'], + [{ mode: 'user' as const }, 'Alice Smith'], + [{ mode: 'combined' as const, modePriority: 'user' as const }, 'Alice Smith'], + ])('with %o names %s beside the avatar', async (props, name) => { + await renderUserButton(props); + + expect(within(trigger()).getByText(name)).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('still names the account in user mode where personal is hidden and none is active', async () => { + await renderUserButton({ mode: 'user', hidePersonal: true }, signedIn({ client: fapiClient([personalSession]) })); + + expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument(); + }); + + it('renders the avatar alone when the label is off', async () => { + await renderUserButton({ mode: 'organization', renderTriggerLabel: false }); + + expect(trigger()).toHaveAccessibleName('Open account menu for Acme'); + expect(within(trigger()).queryByText('Acme')).toBeNull(); + }); + + it('names the active organization before its membership list has loaded', async () => { + serveFapi(signedIn()); + const memberships = holdRequests('get', '/v1/me/organization_memberships'); + await renderWithClerk(tree({ mode: 'organization' })); + + expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); + await userEvent.setup().click(trigger()); + expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); + + memberships.release(); + await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); + }); + }); }); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx deleted file mode 100644 index 3a8f4092bba..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx +++ /dev/null @@ -1,922 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { useOrganization } from '@clerk/shared/react'; -import type { CustomPage } from '@clerk/shared/types'; -import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; -import type { UserButtonModalProps, UserButtonModelOptions } from '../user-button.model'; -import { useUserButtonModel } from '../user-button.model'; - -interface FakeUser { - id: string; - firstName: string | null; - lastName: string | null; - username: string | null; - primaryEmailAddress: { emailAddress: string } | null; - primaryPhoneNumber?: { phoneNumber: string } | null; - primaryWeb3Wallet?: { web3Wallet: string } | null; - imageUrl: string; - organizationMemberships: unknown[]; - createOrganizationEnabled: boolean; -} - -interface FakeSession { - id: string; - user: FakeUser; -} - -interface FakeList { - data: unknown[]; - count: number; - hasNextPage: boolean; - isLoading: boolean; - revalidate: ReturnType; -} - -let isUserLoaded: boolean; -let isSessionLoaded: boolean; -let isOrgLoaded: boolean; -let user: FakeUser | null; -let session: { id: string; checkAuthorization: ReturnType } | null; -let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; -let userMemberships: FakeList; -let userInvitations: FakeList; -let userSuggestions: FakeList; -let signedInSessions: FakeSession[]; -let pagingRef: (element: HTMLElement | null) => void; -let singleSessionMode: boolean; -let branded: boolean; -let forceOrganizationSelection: boolean; -let organizationsEnabled: boolean; -let afterSwitchSessionUrl: string; -// False stands for the window before clerk-js has hydrated it, which the model has to sit out. -let environmentHydrated: boolean; - -// Built per read rather than once, so a test setting any of the flags above is answered by it. -function environment() { - return environmentHydrated - ? { - displayConfig: { afterSwitchSessionUrl, branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } - : null; -} - -let setActive: ReturnType; -let signOut: ReturnType; -let navigate: ReturnType; -let openUserProfile: ReturnType; -let openOrganizationProfile: ReturnType; -let openCreateOrganization: ReturnType; -let openInviteMembers: ReturnType; -let checkAuthorization: ReturnType; -let getContainer: () => HTMLElement | null; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })), - // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather - // than that some function did. - usePortalRoot: () => getContainer, - useClerk: () => ({ - navigate, - setActive, - signOut, - openUserProfile, - openOrganizationProfile, - openCreateOrganization, - openInviteMembers, - buildUserProfileUrl: () => '/user-profile', - buildOrganizationProfileUrl: () => '/org-profile', - buildCreateOrganizationUrl: () => '/create-org', - buildSignInUrl: () => '/sign-in', - buildAfterSignOutUrl: () => '/after-sign-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', - client: { signedInSessions }, - __internal_environment: environment(), - }), - }; -}); - -// The model reads its three paginated lists through the Mosaic in-view helper, so the fetch -// boundary is stubbed there rather than at `useOrganizationList`. -vi.mock('../../../hooks/useOrganizationListInView', () => ({ - useOrganizationListInView: vi.fn(() => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef })), -})); - -function acceptable( - id: string, - orgId: string, - orgName: string, - status: 'pending' | 'accepted' | 'revoked' | 'expired' = '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 } }; -} - -function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { - return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; -} - -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', - organizationMemberships: [], - createOrganizationEnabled: true, - }; - session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; - organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; - userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); - userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); - userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); - pagingRef = vi.fn(); - singleSessionMode = false; - branded = true; - forceOrganizationSelection = false; - organizationsEnabled = true; - afterSwitchSessionUrl = '/after-switch'; - environmentHydrated = true; - 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', - organizationMemberships: [], - createOrganizationEnabled: true, - }, - }, - ]; - setActive = vi.fn().mockResolvedValue(undefined); - signOut = vi.fn().mockResolvedValue(undefined); - navigate = vi.fn().mockResolvedValue(undefined); - openUserProfile = vi.fn(); - openOrganizationProfile = vi.fn(); - openCreateOrganization = vi.fn(); - openInviteMembers = vi.fn(); - getContainer = () => null; -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -function Harness({ modals, ...options }: UserButtonModelOptions & { modals?: UserButtonModalProps } = {}) { - const c = useUserButtonModel(options, modals); - if (c.status !== 'ready') { - return {c.status}; - } - return ( -
- {c.status} - {c.activeSession.name} - {c.activeSession.identifier} - {c.activeSession.sessionId} - {JSON.stringify(c.activeOrganization)} - {String(c.hasOrganizations)} - {String(c.organizationsEnabled)} - {String(c.renderBranding)} - {String(c.hidePersonal)} - {String(c.organizationsLoading)} - {c.additionalSessions.map(a => a.sessionId).join(',')} - {String(c.paging?.hasMore)} - {String(c.paging?.ref === pagingRef)} - {String(Boolean(c.onInviteMembers))} - {String(Boolean(c.onSignOutAll))} - {String(Boolean(c.onAddAccount))} - {String(Boolean(c.onCreateOrganization))} - {JSON.stringify(c.memberships)} - {JSON.stringify(c.suggestions)} - {JSON.stringify(c.invitations)} - - - - - - - - - - - - -
- ); -} - -function memberships() { - return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); -} - -function invitations() { - return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); -} - -function activeOrganization() { - return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null'); -} - -describe('useUserButtonModel', () => { - it('is loading until the user, session, and organization are all loaded', () => { - isUserLoaded = false; - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - isUserLoaded = true; - isSessionLoaded = false; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - isSessionLoaded = true; - isOrgLoaded = false; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - }); - - // Every instance-level answer the surface needs — organizations, single-session, forced - // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready - // without it would mean guessing at all three and rearranging once it lands. - it('is loading until the environment has hydrated', () => { - environmentHydrated = false; - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - environmentHydrated = true; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('ready'); - }); - - it('reports whether the instance has organizations at all', () => { - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true'); - expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); - - cleanup(); - organizationsEnabled = false; - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false'); - expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); - }); - - it('does not fetch the organization lists until the environment says they are on', () => { - environmentHydrated = false; - const { rerender } = render(); - expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); - - environmentHydrated = true; - rerender(); - expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); - }); - - it('does not treat reading the active organization as a request to enable them', () => { - render(); - expect(useOrganization).toHaveBeenCalledWith({ - __internal_skipAttemptToEnableOrganizations: true, - }); - }); - - it('is hidden when loaded but there is no active user', () => { - user = null; - render(); - expect(screen.getByTestId('status')).toHaveTextContent('hidden'); - }); - - it('maps the active account and prefers first+last > username > email for the name', () => { - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('ready'); - expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith'); - expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1'); - - user = { ...(user as FakeUser), firstName: null, lastName: null }; - rerender(); - expect(screen.getByTestId('active-name')).toHaveTextContent('alice'); - - user = { ...user, username: null }; - rerender(); - expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com'); - }); - - it('identifies the active account by username, then email, then phone, then wallet', () => { - const { rerender } = render(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice'); - - user = { ...(user as FakeUser), username: null }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com'); - - user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100'); - - user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc'); - }); - - it('describes the active organization whole, and null in personal mode', () => { - const { rerender } = render(); - expect(activeOrganization()).toMatchObject({ - kind: 'membership', - organizationId: 'org_1', - name: 'Acme', - imageUrl: 'https://img/acme', - membersCount: 3, - }); - - organization = null; - rerender(); - expect(activeOrganization()).toBeNull(); - }); - - it('names the active organization from the organization itself, not the membership list', () => { - userMemberships = list([], 0, false, true); - render(); - - expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' }); - }); - - it('reports the organization list as loading until every one of its three parts has landed', () => { - const { rerender } = render(); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false'); - - userSuggestions = list([], 0, false, true); - rerender(); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - }); - - it('derives hasOrganizations from the membership count, not the array length', () => { - userMemberships = list([membership('org_1', 'Acme', 3)], 0); - const { rerender } = render(); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('false'); - - userMemberships = list([], 5); - rerender(); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); - }); - - // Waiting on the list would open a workspace section under every personal-only account, then - // take it away again. - it('answers hasOrganizations from the user resource before any list has loaded', () => { - userMemberships = list([], 0, false, true); - user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; - render(); - - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); - }); - - it('carries only sessions in additionalSessions, excluding the active one', () => { - render(); - expect(screen.getByTestId('additional')).toHaveTextContent('sess_2'); - expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1'); - }); - - it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => { - render(); - - const rows = memberships(); - expect(rows[0]).toMatchObject({ - kind: 'membership', - organizationId: 'org_1', - name: 'Acme', - membersCount: 3, - }); - - const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); - expect(suggestions[0]).toMatchObject({ - kind: 'suggestion', - id: 'sug_1', - organizationId: 'org_2', - name: 'Beta', - status: 'pending', - }); - - expect(invitations()[0]).toMatchObject({ - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_3', - organizationName: 'Gamma', - status: 'pending', - }); - }); - - it('lists invitations still open to the account, dropping the revoked and expired ones', () => { - userInvitations = list( - [ - acceptable('inv_1', 'org_3', 'Gamma'), - acceptable('inv_2', 'org_4', 'Delta', 'accepted'), - acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'), - acceptable('inv_4', 'org_6', 'Zeta', 'expired'), - ], - 4, - ); - render(); - - expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']); - }); - - it('reports more to page in when any of the three lists has a next page', () => { - const { rerender } = render(); - expect(screen.getByTestId('has-more')).toHaveTextContent('false'); - expect(screen.getByTestId('paging-ref')).toHaveTextContent('true'); - - userSuggestions = list([], 0, true); - rerender(); - expect(screen.getByTestId('has-more')).toHaveTextContent('true'); - }); - - it('offers inviting members only with the manage-memberships permission', () => { - const { rerender } = render(); - expect(screen.getByTestId('can-invite')).toHaveTextContent('true'); - expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' }); - - checkAuthorization.mockReturnValue(false); - rerender(); - expect(screen.getByTestId('can-invite')).toHaveTextContent('false'); - }); - - it('selects an organization via setActive, with no redirect unless one is configured', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - - rerender(); - fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' }); - - rerender( `/o/${org.name}`} />); - fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' }); - }); - - // `null` is Clerk's own name for the personal workspace, and there is no organization for - // `afterSelectOrganizationUrl` to resolve against. - it('selects the personal workspace by clearing the active organization', () => { - render(); - - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); - }); - - it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' }); - - rerender( `/u/${u.username}`} />); - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' }); - }); - - // The two are configured apart, so routing the personal workspace leaves the organizations alone. - it('keeps the personal redirect off the organizations', () => { - render(); - - fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - }); - - // An instance that requires an organization has no personal workspace: clerk-js refuses - // `setActive({ organization: null })` outright there, so offering the switch would offer nothing. - it('reports no personal workspace where the instance forces an organization', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('false'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - // An app whose organizations are the whole product withholds it itself. The instance setting is - // the other way in, and neither one can be talked out of it by the other. - it('lets the app withhold the personal workspace on an instance that allows one', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - it('switches sessions and routes each sign out to the URL that matches what is left', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('switch')); - expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); - - // Another account stays signed in, so this is a single sign out, not a full one. - fireEvent.click(screen.getByText('sign-out-one')); - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' }); - - fireEvent.click(screen.getByText('sign-out-all')); - expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); - - signedInSessions = signedInSessions.slice(0, 1); - rerender(); - fireEvent.click(screen.getByText('sign-out-one')); - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' }); - }); - - // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and - // strands the account, so the switch hands `setActive` a callback that answers both cases. - it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => { - render(); - fireEvent.click(screen.getByText('switch')); - - expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - const decorateUrl = vi.fn((url: string) => url); - - await act(async () => { - await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl }); - }); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); - - await act(async () => { - await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); - }); - expect(navigate).toHaveBeenCalledWith('/after-switch'); - // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it. - expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); - }); - - it('does not navigate after a session switch when no after-switch URL is set', async () => { - afterSwitchSessionUrl = ''; - render(); - fireEvent.click(screen.getByText('switch')); - - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - const decorateUrl = vi.fn((url: string) => url); - await act(async () => { - await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); - }); - - expect(navigate).not.toHaveBeenCalled(); - expect(decorateUrl).not.toHaveBeenCalled(); - }); - - it('prefers the signInUrl prop over the instance sign-in URL for add-account and task routing', async () => { - render(); - - fireEvent.click(screen.getByText('add-account')); - expect(navigate).toHaveBeenCalledWith('/join'); - - fireEvent.click(screen.getByText('switch')); - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - await act(async () => { - await navigateOnSetActive({ - session: { currentTask: { key: 'choose-organization' } }, - decorateUrl: (url: string) => url, - }); - }); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/join')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); - expect(navigate).not.toHaveBeenCalledWith(expect.stringContaining('/sign-in')); - }); - - it('prefers the afterSwitchSessionUrl prop over the instance URL', async () => { - render(); - fireEvent.click(screen.getByText('switch')); - - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - const decorateUrl = vi.fn((url: string) => url); - await act(async () => { - await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); - }); - - expect(navigate).toHaveBeenCalledWith('/app-switch'); - expect(decorateUrl).toHaveBeenCalledWith('/app-switch'); - }); - - // An instance can restrict who may open an organization, and a user at their creation limit is - // restricted the same way. Offering the action anyway lands them on a page that turns them away. - it('drops create-organization for a user who cannot open one', () => { - const { rerender } = render(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('true'); - - user = { ...(user as FakeUser), createOrganizationEnabled: false }; - rerender(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('false'); - }); - - it('drops sign-out-all and add-account in single-session mode', () => { - singleSessionMode = true; - render(); - expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false'); - expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); - }); - - // An instance that has paid the branding off carries none of it, and the environment is the only - // place that answer lives. - it('carries the branding the instance is on, not the branding everyone gets', () => { - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('true'); - - cleanup(); - branded = false; - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('false'); - }); - - // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic - // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. - it('opens the profile modals for manage-account and manage-org', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalled(); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); - - expect(navigate).not.toHaveBeenCalled(); - }); - - // An app that mounts the button inside its own dialog or popover puts a portal root around it, and - // the modal has to land there too or it renders behind the surface that opened it. - it('opens the profile modals into the portal root the app configured', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); - }); - - // Custom pages are bridged into this DOM-callback form by the container, since it is the layer - // that can render their portals. All the model owes them is a ride to the modal. - it('hands the profile modal the custom pages it was given', () => { - const customPages = [ - { - label: 'Terms', - url: 'terms', - mount: vi.fn(), - unmount: vi.fn(), - mountIcon: vi.fn(), - unmountIcon: vi.fn(), - }, - ]; - render(); - - fireEvent.click(screen.getByText('manage-account')); - - expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); - }); - - it('hands the user profile modal its OAuth scopes, API keys options, and appearance', () => { - const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] }; - const apiKeysProps = { showDescription: true, hide: false }; - const appearance = { variables: { colorPrimary: 'red' } }; - render(); - - fireEvent.click(screen.getByText('manage-account')); - - expect(openUserProfile).toHaveBeenCalledWith({ getContainer, additionalOAuthScopes, apiKeysProps, appearance }); - }); - - it('hands the organization profile modal its custom pages, appearance, and where leaving lands', () => { - const customPages: CustomPage[] = [{ label: 'members' }]; - const appearance = { variables: { colorPrimary: 'red' } }; - render( - , - ); - - fireEvent.click(screen.getByText('manage-org')); - - expect(openOrganizationProfile).toHaveBeenCalledWith({ - getContainer, - customPages, - appearance, - afterLeaveOrganizationUrl: '/left', - }); - }); - - // clerk-js fills the `:param` template itself once the organization exists. - it('hands the create-organization modal where creating lands and whether to skip inviting', () => { - render( - , - ); - - fireEvent.click(screen.getByText('create-org')); - - expect(openCreateOrganization).toHaveBeenCalledWith({ - getContainer, - afterCreateOrganizationUrl: '/orgs/:slug', - skipInvitationScreen: true, - }); - }); - - // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass - // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. - it('navigates to a profile URL when one is given, and only for that profile', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); - }); - - it('navigates to an organization profile URL when one is given', () => { - render(); - - fireEvent.click(screen.getByText('manage-org')); - - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); - - // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, - // so passing both has to resolve the same as passing the URL alone. - it('accepts an explicit navigation mode alongside a URL', () => { - render( - , - ); - - fireEvent.click(screen.getByText('manage-org')); - - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); - - // Invite opens its own modal rather than following manage-org: there is no invite page to route - // to, so an app that routes organization management to its own page still gets the form here. - it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => { - render(); - - fireEvent.click(screen.getByText('invite-members')); - - expect(openInviteMembers).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - }); - - // Creating an organization resolves like the two profiles do: a modal unless a URL routes - // instead. Adding an account always leaves, since signing in cannot happen inside the popover. - it('opens the create-organization modal into the portal root, and navigates for add-account', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText('add-account')); - expect(navigate).toHaveBeenCalledWith('/sign-in'); - }); - - it('navigates to a create-organization URL when one is given', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - - expect(navigate).toHaveBeenCalledWith('/new-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit - // `navigation` asks for. - it('falls back to the clerk create-organization URL for an explicit navigation mode', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - - expect(navigate).toHaveBeenCalledWith('/create-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => { - render(); - - // Accepting an invitation joins the organization, so the membership list is stale too. - const invitation = userInvitations.data[0] as ReturnType; - await act(async () => { - fireEvent.click(screen.getByText('accept-invitation')); - }); - expect(invitation.accept).toHaveBeenCalledTimes(1); - expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); - expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); - - // A suggestion only files a request an admin has yet to approve, so nothing has been joined. - const suggestion = userSuggestions.data[0] as ReturnType; - await act(async () => { - fireEvent.click(screen.getByText('accept-suggestion')); - }); - expect(suggestion.accept).toHaveBeenCalledTimes(1); - expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); - expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); - }); - - it('does not treat a failed list refresh as a failed accept', async () => { - userInvitations.revalidate.mockRejectedValueOnce(new Error('stale')); - userMemberships.revalidate.mockRejectedValueOnce(new Error('stale')); - userSuggestions.revalidate.mockRejectedValueOnce(new Error('stale')); - const { result } = renderHook(() => useUserButtonModel()); - if (result.current.status !== 'ready') { - throw new Error('expected ready'); - } - - await expect(result.current.onAcceptInvitation?.('inv_1')).resolves.toBeUndefined(); - await expect(result.current.onAcceptSuggestion?.('sug_1')).resolves.toBeUndefined(); - }); -}); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx deleted file mode 100644 index efd040cb70a..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx +++ /dev/null @@ -1,1068 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { scrollAreaViewport } from '../../../components/scroll-area'; -import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserButtonProps } from '../user-button.view'; -import { userButtonBusyKeys, UserButtonView } from '../user-button.view'; - -// `mode` is the view's own prop, so what each of the three surfaces carries and withholds is settled -// here. One describe per mode; the describes after them cover what the modes share. - -const alice = { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }; -const bob = { sessionId: 'sess_2', name: 'Bob Jones', identifier: 'bob@example.com' }; - -const foundry = { - kind: 'membership', - organizationId: 'org_1', - name: 'Foundry', - membersCount: 24, - planLabel: 'Pro', -} as const; - -const otherCo = { kind: 'membership', organizationId: 'org_2', name: 'Other Co' } as const; - -const gamma = { - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_3', - organizationName: 'Gamma', - status: 'pending', -} as const; - -const beta = { kind: 'suggestion', id: 'sug_1', organizationId: 'org_4', name: 'Beta', status: 'pending' } as const; - -/** - * Every callback the connected container passes, so a test opts a surface *out* of an affordance - * rather than having to opt into it. `combined` is the container's own default. - */ -function renderView(props: Partial = {}) { - return render( - - - , - ); -} - -const popup = () => screen.getByRole('dialog', { name: 'Account' }); - -// The `cl-` slot classes are Mosaic's public theming hooks, so they are a stable handle on the -// popup's sections rather than an implementation detail. -const groups = () => Array.from(popup().querySelectorAll('.cl-user-button-group')); -const header = () => { - const node = popup().querySelector('.cl-user-button-header'); - if (!node) { - throw new Error('The popup has no header'); - } - return node; -}; -const labels = (group: HTMLElement | undefined) => - Array.from(group?.querySelectorAll(".cl-user-button-item-label[data-variant='default']") ?? []).map( - node => node.textContent ?? '', - ); -const row = (group: HTMLElement | undefined, label: string) => - Array.from(group?.querySelectorAll('.cl-user-button-item') ?? []).find( - node => node.querySelector(".cl-user-button-item-label[data-variant='default']")?.textContent === label, - ); - -const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.split(' ') ?? []; - -/** The workspace list: the one group in the popup that scrolls. */ -const workspaceList = () => groups().find(group => scrollClasses.every(name => group.classList.contains(name))); - -/** Opens the accounts flyout at the foot, and hands back the menu it opens. */ -async function openAccounts(act: ReturnType) { - await act.click(screen.getByRole('button', { name: 'Switch account' })); - return screen.findByRole('menu'); -} - -describe('UserButtonView, user mode', () => { - function renderUserMode(props: Partial = {}) { - return renderView({ - mode: 'user', - // All of this is the organization side, and is here to prove the surface ignores it. - hasOrganizations: true, - activeOrganization: foundry, - memberships: [foundry, otherCo], - invitations: [gamma], - ...props, - }); - } - - it('names the account in the header, never the organization that is active', () => { - renderUserMode(); - - expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); - expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); - expect(screen.queryByText('Foundry')).toBeNull(); - }); - - it('drops the identifier line when it would only repeat the label', () => { - renderUserMode({ activeSession: { ...alice, name: 'alice@example.com' } }); - - expect(within(header()).getAllByText('alice@example.com')).toHaveLength(1); - }); - - it('lists no workspaces at all, and offers no way to make one', () => { - renderUserMode(); - - expect(workspaceList()).toBeUndefined(); - expect(screen.queryByText('Personal account')).toBeNull(); - expect(screen.queryByText('Gamma')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); - }); - - it('signs out of the account from the header, beside the gear', async () => { - const onSignOutSession = vi.fn(); - renderUserMode({ onSignOutSession }); - - expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); - - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); - }); - - it('spins the header sign-out while it is in flight', () => { - renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1', 'header') }); - - const button = screen.getByRole('button', { name: 'Sign out' }); - expect(button).toHaveAttribute('type', 'button'); - expect(button).toHaveAttribute('aria-busy', 'true'); - expect(within(button).getByRole('progressbar')).toBeInTheDocument(); - }); - - it('opens the accounts from the foot rather than listing them inline', async () => { - renderUserMode(); - - expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull(); - - const items = within(await openAccounts(userEvent.setup())).getAllByRole('menuitem'); - - expect(items).toHaveLength(3); - expect(items[0]).toHaveAccessibleName('alice@example.com'); - expect(items[1]).toHaveAccessibleName('bob@example.com'); - expect(items[2]).toHaveAccessibleName('Add account'); - }); - - it('signs out of every account at the foot', () => { - renderUserMode(); - - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeInTheDocument(); - }); -}); - -describe('UserButtonView, organization mode', () => { - function renderOrganizationMode(props: Partial = {}) { - return renderView({ - mode: 'organization', - hasOrganizations: true, - activeOrganization: foundry, - memberships: [foundry, otherCo], - ...props, - }); - } - - it('heads the surface with the active organization and what can be done to it', () => { - renderOrganizationMode(); - - expect(within(header()).getByText('Foundry')).toBeInTheDocument(); - expect(within(header()).getByText('Pro · 24 members')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - }); - - it('falls back to the account in the header where no organization is active', () => { - renderOrganizationMode({ activeOrganization: null }); - - expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); - expect(within(header()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - expect(within(header()).getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); - }); - - // `hidePersonal` withholds the workspace, so a missing org is no selection — not the account. - it('names no organization selected where personal is hidden and none is active', () => { - renderOrganizationMode({ hidePersonal: true, activeOrganization: null }); - - expect(within(header()).getByText('No organization selected')).toBeInTheDocument(); - expect(screen.queryByText('Alice Smith')).toBeNull(); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); - }); - - // The header acts on the active organization, which is known whole before the list it belongs to - // lands. Invite and the gear act on the same organization, so they answer together. - it('offers to invite while the membership list is still in flight', () => { - renderOrganizationMode({ memberships: [], hasOrganizations: false, organizationsLoading: true }); - - expect(within(header()).getByText('Foundry')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - }); - - it('lists the workspaces, the personal one among them', () => { - renderOrganizationMode(); - - expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry', 'Other Co']); - }); - - it('carries no account rows, not even the one it belongs to', () => { - renderOrganizationMode(); - - expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Actions for alice@example.com' })).toBeNull(); - // Nothing carries "Sign out" either: with no row to hang it off, the header would be the only - // place left, and that slot is the organization's. - expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull(); - }); - - it('trails the workspaces with "Create organization", in place of the account actions', () => { - renderOrganizationMode(); - - expect(within(workspaceList()).getByRole('button', { name: 'Create organization' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); - }); -}); - -describe('UserButtonView, combined mode', () => { - function renderCombined(props: Partial = {}) { - return renderView({ - hasOrganizations: true, - activeOrganization: foundry, - memberships: [foundry, otherCo], - ...props, - }); - } - - it('heads the surface with the active organization by default', () => { - renderCombined(); - - // The subtitle is the header's alone; the row below it carries only a label. - expect(screen.getByText('Pro · 24 members')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - }); - - it('heads the workspace list with the active account and its own actions', async () => { - const onSignOutSession = vi.fn(); - const act = userEvent.setup(); - renderCombined({ onSignOutSession }); - - await act.click(screen.getByRole('button', { name: 'Actions for alice@example.com' })); - - expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); - await act.click(screen.getByRole('menuitem', { name: 'Sign out' })); - - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'organizationsHeading'); - }); - - it('trails the workspaces with "Create organization", below the last of them', async () => { - const onCreateOrganization = vi.fn(); - renderCombined({ onCreateOrganization }); - - // Not `labels`: the row is an action rather than a workspace, so its label is the interactive one. - const rows = Array.from(workspaceList()?.querySelectorAll('.cl-user-button-item-label') ?? []); - expect(rows.at(-1)?.textContent).toBe('Create organization'); - await userEvent.setup().click(screen.getByRole('button', { name: 'Create organization' })); - - expect(onCreateOrganization).toHaveBeenCalled(); - }); - - it('keeps the header sign-out off, since the account row carries it', () => { - renderCombined(); - - expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull(); - }); - - it('switches account from the flyout, checking the one it is already on', async () => { - const onSwitchSession = vi.fn(); - const act = userEvent.setup(); - renderCombined({ onSwitchSession }); - - const menu = await openAccounts(act); - const active = within(menu).getByRole('menuitem', { name: 'alice@example.com' }); - const other = within(menu).getByRole('menuitem', { name: 'bob@example.com' }); - - expect(active).toHaveAttribute('aria-current', 'true'); - expect(other).not.toHaveAttribute('aria-current'); - - await act.click(other); - - expect(onSwitchSession).toHaveBeenCalledWith('sess_2'); - }); - - it('keeps "Add account" in the flyout rather than at the foot', async () => { - renderCombined(); - - expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); - - const menu = await openAccounts(userEvent.setup()); - - expect(within(menu).getByRole('menuitem', { name: 'Add account' })).toBeInTheDocument(); - }); - - it('takes "Add account" at the foot where there is no second account to switch to', () => { - renderCombined({ additionalSessions: [] }); - - expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); - expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); - }); - - it('keeps the account row with no organizations to head', () => { - renderCombined({ hasOrganizations: false, activeOrganization: null, memberships: [] }); - - expect(screen.getByRole('button', { name: 'Actions for alice@example.com' })).toBeInTheDocument(); - // The section is there, holding the account row alone. - expect(workspaceList()).toBeDefined(); - expect(labels(workspaceList())).toEqual([]); - expect(screen.queryByText('Personal account')).toBeNull(); - }); -}); - -describe('UserButtonView, the workspace list', () => { - function renderList(props: Partial = {}) { - return renderView({ - hasOrganizations: true, - activeOrganization: foundry, - memberships: [foundry], - ...props, - }); - } - - // Accepting an invitation joins; a suggestion only files a request, so invitations lead. - it('lists the workspaces held, then the invitations, then the suggestions', () => { - renderList({ invitations: [gamma], suggestions: [beta] }); - - expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry', 'Gamma', 'Beta']); - }); - - // `auto` rather than `stable`: a reserved gutter would inset short lists off the edge the header - // and footer align to. - it('scrolls through the shared scroll area, at an automatic gutter', () => { - renderList(); - - expect(scrollClasses.length).toBeGreaterThan(0); - expect(workspaceList()).toBeDefined(); - }); - - // The check beside the active row is decorative, so on its own it leaves that row reading to a - // screen reader exactly like the ones there is still somewhere to switch to. - it('names the active workspace as the current one', () => { - renderList({ memberships: [foundry, otherCo] }); - - expect(row(workspaceList(), 'Foundry')).toHaveAttribute('aria-current', 'true'); - expect(row(workspaceList(), 'Other Co')).not.toHaveAttribute('aria-current'); - expect(row(workspaceList(), 'Personal account')).not.toHaveAttribute('aria-current'); - }); - - it('names the personal workspace as the current one where no organization is active', () => { - renderList({ activeOrganization: null }); - - expect(row(workspaceList(), 'Personal account')).toHaveAttribute('aria-current', 'true'); - expect(row(workspaceList(), 'Foundry')).not.toHaveAttribute('aria-current'); - }); - - it('hands the paging sentinel to the in-view ref only while more pages remain', () => { - const ref = vi.fn(); - const { unmount } = renderList({ paging: { ref, hasMore: false } }); - expect(ref).not.toHaveBeenCalled(); - unmount(); - - renderList({ paging: { ref, hasMore: true } }); - expect(ref).toHaveBeenCalledWith(expect.any(HTMLElement)); - }); - - // Switching into an organization is not a one-way door: the account's own workspace is listed - // alongside the organizations, so there is always a way back out of one. - describe('the personal workspace', () => { - it('switches back out of the active organization', async () => { - const onSelectOrganization = vi.fn(); - renderList({ onSelectOrganization }); - - await userEvent.setup().click(screen.getByRole('button', { name: 'Personal account' })); - - expect(onSelectOrganization).toHaveBeenCalledWith(null); - }); - - // Named for what it is among organizations rather than for the account, the way the existing - // OrganizationSwitcher names it. The trigger and header still name the account itself. - it('names it "Personal account" rather than repeating the account', () => { - renderList(); - - expect(labels(workspaceList())).toContain('Personal account'); - expect(labels(workspaceList())).not.toContain('Alice Smith'); - }); - - it('checks it, and offers no switch, where it is what is active', () => { - renderList({ activeOrganization: null }); - - expect(screen.getByText('Personal account')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull(); - }); - - it('spins it while the switch is in flight', () => { - renderList({ pendingKey: userButtonBusyKeys.selectOrganization(null) }); - - const row = screen.getByRole('button', { name: 'Personal account' }); - expect(row).toHaveAttribute('aria-disabled', 'true'); - expect(row.querySelector('.cl-spinner')).not.toBeNull(); - }); - - // Withheld rather than stood down: the workspace does not exist here, so there is no switch to - // make available later. - it('stays out of a surface that has no personal workspace', () => { - renderList({ hidePersonal: true, memberships: [foundry, otherCo] }); - - expect(screen.queryByText('Personal account')).toBeNull(); - // The organizations are still listed; it is only the way back out of them that is gone. - expect(labels(workspaceList())).toEqual(['Foundry', 'Other Co']); - }); - }); - - describe('the rows on offer', () => { - it('offers to accept an invitation and to join a suggestion', () => { - renderList({ invitations: [gamma], suggestions: [beta] }); - - expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument(); - }); - - // The button reads the same on every offer, and the workspace it acts on is the row's title - // beside it rather than anything inside it, so on its own `Accept` does not say which. Pointed - // at that title rather than at a second copy of the name. - it('describes each offer by the workspace it acts on', () => { - renderList({ invitations: [gamma], suggestions: [beta] }); - - expect(screen.getByRole('button', { name: 'Accept' })).toHaveAccessibleDescription('Gamma'); - expect(screen.getByRole('button', { name: 'Join' })).toHaveAccessibleDescription('Beta'); - }); - - // A pending row has to be reachable before there is a membership, or an account holding nothing - // but an invitation would open onto a surface with no way to accept it. - it('lists them with no memberships to list them beside', () => { - renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], invitations: [gamma] }); - - expect(labels(workspaceList())).toEqual(['Personal account', 'Gamma']); - expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument(); - }); - - // Every other affordance on the surface swaps its icon for a spinner, but these carry a label - // rather than an icon, so the spinner goes inside the button instead of taking its place. - it('spins inside the join button rather than replacing it', () => { - renderList({ suggestions: [beta], pendingKey: userButtonBusyKeys.acceptSuggestion('sug_1') }); - - const join = screen.getByRole('button', { name: 'Join' }); - expect(join).toHaveAttribute('aria-busy', 'true'); - expect(within(join).getByRole('progressbar')).toBeInTheDocument(); - // The row itself stays as it was: the button is what reports the action, not the trailing edge. - expect(row(workspaceList(), 'Beta')?.querySelector('.cl-spinner')).toBe(within(join).getByRole('progressbar')); - }); - - it('spins inside the accept button too', () => { - renderList({ invitations: [gamma], pendingKey: userButtonBusyKeys.acceptInvitation('inv_1') }); - - const accept = screen.getByRole('button', { name: 'Accept' }); - expect(accept).toHaveAttribute('aria-busy', 'true'); - expect(within(accept).getByRole('progressbar')).toBeInTheDocument(); - }); - - // `progressbar` is named in its own right rather than folding into the button above it, so that - // name is copy this surface owns. `SubmitButton`'s fallback is an untranslated literal. - it('names the pending indicator in the copy this surface carries', () => { - renderList({ invitations: [gamma], pendingKey: userButtonBusyKeys.acceptInvitation('inv_1') }); - - const accept = screen.getByRole('button', { name: 'Accept' }); - expect(within(accept).getByRole('progressbar')).toHaveAccessibleName('pending'); - }); - - it('reports an accepted suggestion instead of offering to join it again', () => { - renderList({ suggestions: [{ ...beta, status: 'accepted' }] }); - - expect(screen.queryByRole('button', { name: 'Join' })).toBeNull(); - expect(screen.getByText('Requested')).toBeInTheDocument(); - }); - - it('lists an accepted invitation as a workspace to switch to', async () => { - const onSelectOrganization = vi.fn(); - renderList({ invitations: [{ ...gamma, status: 'accepted' }], onSelectOrganization }); - - expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Gamma' })); - - expect(onSelectOrganization).toHaveBeenCalledWith('org_3'); - }); - - // The membership list catches up a moment after the accept, and for that moment the - // organization is in both lists. - it('drops an accepted invitation the membership list has caught up with', () => { - renderList({ - invitations: [{ ...gamma, status: 'accepted' }], - memberships: [foundry, { kind: 'membership', organizationId: 'org_3', name: 'Gamma' }], - }); - - expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry', 'Gamma']); - }); - - it('drops one for the organization that is already active', () => { - renderList({ - invitations: [{ ...gamma, status: 'accepted' }], - activeOrganization: { kind: 'membership', organizationId: 'org_3', name: 'Gamma' }, - }); - - // The header names it; the list does not offer to switch to what is already active. - expect(screen.queryByRole('button', { name: 'Gamma' })).toBeNull(); - }); - }); - - // Memberships, invitations and suggestions are three requests that land at three different - // moments; showing each as it arrives walks the list in in stages. - describe('while its first page is in flight', () => { - it('withholds every row behind one placeholder', () => { - renderList({ organizationsLoading: true, invitations: [gamma] }); - - expect(screen.getByText('Loading organizations…')).toBeInTheDocument(); - expect(workspaceList()).toBeDefined(); - expect(labels(workspaceList())).toEqual([]); - }); - - it('leaves the account row above it alone, since it does not wait on the list', () => { - renderList({ organizationsLoading: true }); - - expect(screen.getByRole('button', { name: 'Actions for alice@example.com' })).toBeInTheDocument(); - }); - - it('drops the placeholder once the list has landed', () => { - renderList(); - - expect(screen.queryByText('Loading organizations…')).toBeNull(); - expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry']); - }); - - // `hasOrganizations` is answered before the lists are fetched, so an account with none never - // opens a section that then disappears under it. - it('stays out of a surface with nothing to list', () => { - renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], organizationsLoading: true }); - - expect(screen.queryByText('Loading organizations…')).toBeNull(); - }); - }); -}); - -// The foot is the one flat list of actions the surface has; everything else lives in the header or -// behind a `⋯`. So it is where an app's own actions land, and the only list an order can run in. -describe('UserButtonView, the foot', () => { - const terms = { id: 'terms', label: 'Terms of service' }; - const support = { id: 'support', label: 'Support', href: '/support' }; - - const action = () => ({ ...terms, onClick: vi.fn() }); - - /** The foot's rows, in the order it lists them. It is the last group in the popup. */ - const footActions = () => - Array.from(groups().at(-1)?.querySelectorAll(".cl-user-button-item-label[data-variant='interactive']") ?? []).map( - node => node.textContent ?? '', - ); - - // The order the existing UserButton lists them in, above the account rows. - it('leads with the custom rows', () => { - renderView({ customMenuItems: [action(), support] }); - - expect(footActions()).toEqual(['Terms of service', 'Support', 'Switch account', 'Sign out of all accounts']); - }); - - it('runs a custom action on press', async () => { - const item = action(); - renderView({ customMenuItems: [item] }); - - await userEvent.setup().click(screen.getByRole('button', { name: 'Terms of service' })); - - expect(item.onClick).toHaveBeenCalled(); - }); - - it('renders a custom link as one, pointed where it was told', () => { - renderView({ customMenuItems: [support] }); - - expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); - }); - - it('renders the icon a custom row brings', () => { - renderView({ customMenuItems: [{ ...action(), icon: }] }); - - expect(screen.getByTestId('glyph')).toBeInTheDocument(); - }); - - it('orders the rows by the ids it is given', () => { - renderView({ customMenuItems: [action(), support], menuItemOrder: ['signOutAll', 'support'] }); - - expect(footActions()).toEqual(['Sign out of all accounts', 'Support', 'Terms of service', 'Switch account']); - }); - - // Only some of the built-in actions are rows at all, and which of those a surface carries depends - // on its mode, so naming one it has not got is ordinary rather than a mistake. - it('drops an id no row answers to', () => { - renderView({ customMenuItems: [action()], menuItemOrder: ['manageAccount', 'signOutAll', 'nonsense'] }); - - expect(footActions()).toEqual(['Sign out of all accounts', 'Terms of service', 'Switch account']); - }); - - // The accounts slot answers to both ids, so an order set once places it whichever way it resolves. - it.each([ - ['Switch account', [bob]], - ['Add account', []], - ])('orders the accounts slot ahead of a custom row as "%s"', (label, additionalSessions) => { - renderView({ additionalSessions, customMenuItems: [action()], menuItemOrder: ['switchAccount', 'addAccount'] }); - - expect(footActions()[0]).toBe(label); - }); - - it('carries the custom rows on an org-only surface too', () => { - renderView({ - mode: 'organization', - hasOrganizations: true, - activeOrganization: foundry, - customMenuItems: [action()], - }); - - expect(footActions()).toEqual(['Terms of service']); - }); - - it('holds a custom action in place, disabled, while another action runs', () => { - renderView({ customMenuItems: [action()], pendingKey: userButtonBusyKeys.switchSession('sess_9') }); - - expect(screen.getByRole('button', { name: 'Terms of service' })).toHaveAttribute('aria-disabled', 'true'); - }); - - it('leaves a custom link followable while another action runs', () => { - renderView({ customMenuItems: [support], pendingKey: userButtonBusyKeys.switchSession('sess_9') }); - - expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); - }); - - // The card owns the mark; the popup only carries the answer through. An instance that has paid - // the branding off carries none of it, the way every other Clerk surface reads - // `displayConfig.branded`. - it('signs the popup with Clerk, and withholds the mark where the instance carries none', () => { - const { unmount } = renderView(); - expect(within(popup()).getByRole('link', { name: 'Clerk' })).toBeInTheDocument(); - - unmount(); - renderView({ renderBranding: false }); - expect(within(popup()).queryByRole('link', { name: 'Clerk' })).toBeNull(); - }); - - // "All accounts" is one account, so the foot keeps its sign-out row and signs out of just that one. - it('signs out of the one account at the foot where there is no second', async () => { - const onSignOutSession = vi.fn(); - renderView({ additionalSessions: [], onSignOutSession }); - - expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); - const rows = within(groups().at(-1) ?? document.body); - await userEvent.click(rows.getByRole('button', { name: 'Sign out' })); - - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'footer'); - }); -}); - -// An avatar's load state dies with the element it hangs off, so a row that stands down stays the -// button it was rather than remounting and dropping its avatar to a placeholder. -describe('UserButtonView, one action at a time', () => { - function surface(pendingKey: string | null, props: Partial = {}) { - return ( - - - - ); - } - - // `aria-disabled` rather than the native attribute, the way `SubmitButton` does it: the row that - // owns the action is standing down too, and natively disabling it would drop it out of the tab - // order just as its spinner appears, taking focus with it. - it.each([ - ['a workspace row', 'Other Co'], - ['the personal row', 'Personal account'], - ['an action row', 'Sign out of all accounts'], - ])('holds %s in place, aria-disabled and still focusable, while another action runs', (_name, label) => { - const { rerender } = render(surface(null)); - const row = screen.getByRole('button', { name: label }); - - rerender(surface(userButtonBusyKeys.switchSession('sess_9'))); - - const stoodDown = screen.getByRole('button', { name: label }); - expect(stoodDown).toBe(row); - expect(stoodDown).toHaveAttribute('aria-disabled', 'true'); - expect(stoodDown).toBeEnabled(); - }); - - // The press leaves focus on the row, so the row is what gets re-read while it works. It takes - // the same pairing as a pending `SubmitButton` — `aria-busy` beside an indicator carrying a name - // of its own — since a row that only stands down `aria-disabled` reads as unavailable instead. - it.each([ - ['a workspace row', 'Other Co', userButtonBusyKeys.selectOrganization('org_2')], - ['an action row', 'Sign out of all accounts', userButtonBusyKeys.signOutAll()], - ['the accounts flyout', 'Switch account', userButtonBusyKeys.switchSession('sess_2')], - [ - 'the account menu', - 'Actions for alice@example.com', - userButtonBusyKeys.signOutSession('sess_1', 'organizationsHeading'), - ], - ])('reports the action on %s that owns it, in place, the way a pending button does', (_name, label, key) => { - const { rerender } = render(surface(null)); - const row = screen.getByRole('button', { name: label }); - - rerender(surface(key)); - - expect(screen.getByRole('button', { name: label })).toBe(row); - expect(row).toHaveAttribute('aria-busy', 'true'); - expect(within(row).getByRole('progressbar')).toHaveAccessibleName('pending'); - }); - - it('spins only the sign-out that was pressed where the surface offers it in several places', () => { - render( - surface(userButtonBusyKeys.signOutSession('sess_1', 'header'), { - activeOrganization: null, - additionalSessions: [], - }), - ); - - const spinners = popup().querySelectorAll('.cl-spinner'); - expect(spinners).toHaveLength(1); - expect(within(header()).getByRole('button', { name: 'Sign out' })).toContainElement(spinners[0]); - }); - - // The rows waiting on it are not running anything, so they carry the indicator's opposite. - it('leaves the rows standing down beside it with nothing to report', () => { - render(surface(userButtonBusyKeys.selectOrganization('org_2'))); - - const row = screen.getByRole('button', { name: 'Personal account' }); - expect(row).not.toHaveAttribute('aria-busy'); - expect(within(row).queryByRole('progressbar')).toBeNull(); - }); - - // Both `⋯` stand down the same way. Withholding what one opens would unmount its trigger, so - // the row would drop its trailing edge for the length of the action and get it back after. - it.each([ - ['the account menu', 'Actions for alice@example.com'], - ['the accounts flyout', 'Switch account'], - ])('holds %s in place, aria-disabled and still focusable, while another action runs', (_name, label) => { - const { rerender } = render(surface(null)); - const row = screen.getByRole('button', { name: label }); - - rerender(surface(userButtonBusyKeys.switchSession('sess_9'))); - - const stoodDown = screen.getByRole('button', { name: label }); - expect(stoodDown).toBe(row); - expect(stoodDown).toHaveAttribute('aria-disabled', 'true'); - expect(stoodDown).toBeEnabled(); - }); - - // `aria-disabled` is advisory, so the row has to drop the press itself. - it('ignores a press on a row that is standing down', async () => { - const onSelectOrganization = vi.fn(); - render(surface(userButtonBusyKeys.switchSession('sess_9'), { onSelectOrganization })); - const row = screen.getByRole('button', { name: 'Other Co' }); - - // The popup takes its own initial focus a frame after it opens. Waiting for that lets the row - // hold the focus it takes next, rather than losing it to a steal that lands mid-press. - await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus()); - - row.focus(); - await userEvent.click(row); - - expect(onSelectOrganization).not.toHaveBeenCalled(); - expect(row).toHaveFocus(); - }); -}); - -describe('UserButtonTrigger', () => { - // Closed, so the only "Foundry" or "Alice Smith" on screen is the trigger's own label. - function renderTrigger(props: Partial = {}) { - return renderView({ - defaultOpen: false, - hasOrganizations: true, - memberships: [foundry], - activeOrganization: foundry, - ...props, - }); - } - - it('names the active organization and its plan', () => { - renderTrigger({ mode: 'organization' }); - - expect(screen.getByText('Foundry')).toBeInTheDocument(); - expect(screen.getByText('Pro')).toBeInTheDocument(); - }); - - it('names the account where no organization heads the trigger', () => { - renderTrigger({ mode: 'user' }); - - expect(screen.getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.queryByText('Pro')).toBeNull(); - }); - - it('names no organization selected where personal is hidden and none is active', () => { - renderTrigger({ mode: 'organization', hidePersonal: true, activeOrganization: null }); - - expect(screen.getByText('No organization selected')).toBeInTheDocument(); - expect(screen.queryByText('Alice Smith')).toBeNull(); - }); - - it('still names the account in user mode when personal is hidden and none is active', () => { - renderTrigger({ mode: 'user', hidePersonal: true, activeOrganization: null }); - - expect(screen.getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.queryByText('No organization selected')).toBeNull(); - }); - - it('names the active organization in combined mode', () => { - renderTrigger({ mode: 'combined' }); - - expect(screen.getByText('Foundry')).toBeInTheDocument(); - expect(screen.getByText('Pro')).toBeInTheDocument(); - }); - - it('names the account, badged with its active organization, where the account leads', () => { - renderTrigger({ mode: 'combined', modePriority: 'user' }); - - const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' }); - expect(within(trigger).getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.queryByText('Pro')).toBeNull(); - expect(trigger.querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F'); - }); - - it('badges the avatar alone the same way', () => { - renderTrigger({ mode: 'combined', modePriority: 'user', renderTriggerLabel: false }); - - const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' }); - expect(trigger.querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F'); - }); - - it('badges nothing where the organization or no organization leads', () => { - renderTrigger({ mode: 'combined' }); - renderTrigger({ mode: 'combined', modePriority: 'user', activeOrganization: null }); - renderTrigger({ mode: 'user', modePriority: 'user' }); - - expect(document.querySelector('.cl-user-button-avatar-badge')).toBeNull(); - }); - - it('renders the avatar alone when the label is off', () => { - renderTrigger({ mode: 'organization', renderTriggerLabel: false }); - - expect(screen.queryByText('Foundry')).toBeNull(); - expect(screen.queryByText('Pro')).toBeNull(); - expect(screen.getByRole('button', { name: 'Open account menu for Foundry' })).toBeInTheDocument(); - }); - - it('keeps the name when only the plan badge is off', () => { - renderTrigger({ mode: 'organization', renderTriggerBadge: false }); - - expect(screen.getByText('Foundry')).toBeInTheDocument(); - expect(screen.queryByText('Pro')).toBeNull(); - }); - - it('ends the label with a caret, and carries none for the avatar alone', () => { - const caret = (props: Partial) => { - const { unmount } = renderTrigger(props); - const found = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-icon'); - unmount(); - return found; - }; - - expect(caret({ mode: 'organization' })).not.toBeNull(); - expect(caret({ mode: 'organization', renderTriggerLabel: false })).toBeNull(); - }); - - it('shrinks the avatar beside a label, and fills the trigger with it alone', () => { - const avatarSize = (props: Partial) => { - const { unmount } = renderTrigger(props); - const avatar = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-avatar'); - const size = avatar?.getAttribute('data-size'); - unmount(); - return size; - }; - - expect(avatarSize({ mode: 'organization' })).toBe('xs'); - expect(avatarSize({ mode: 'organization', renderTriggerLabel: false })).toBe('sm'); - }); - - it('rounds fully only around a user avatar on its own', () => { - const corner = (props: Partial) => { - const { unmount } = renderTrigger(props); - const className = screen.getByRole('button', { name: /Open account menu/ }).className; - unmount(); - return className; - }; - - expect(corner({ mode: 'organization' })).toEqual(corner({ mode: 'user' })); - expect(corner({ mode: 'organization', renderTriggerLabel: false })).not.toEqual( - corner({ mode: 'user', renderTriggerLabel: false }), - ); - }); - - it('names the active organization before its membership list has loaded', () => { - renderTrigger({ mode: 'organization', memberships: [], hasOrganizations: false, organizationsLoading: true }); - - expect(screen.getByText('Foundry')).toBeInTheDocument(); - expect(screen.queryByText('Alice Smith')).toBeNull(); - }); -}); - -describe('UserButtonView, the header', () => { - function renderHeader(props: Partial = {}) { - return renderView({ - hasOrganizations: true, - activeOrganization: foundry, - memberships: [foundry, otherCo], - ...props, - }); - } - - it('carries the slot classes for the trigger, the popover, and the header', () => { - renderHeader(); - - expect(screen.getByRole('button', { name: 'Open account menu for Foundry' }).className).toMatch( - /^cl-popover-trigger cl-user-button-trigger /, - ); - expect(popup().querySelector('.cl-user-button-popover')?.className).toMatch( - /^cl-popover-popup cl-user-button-popover /, - ); - expect(header()).toHaveAttribute('data-layout', 'stacked'); - expect(header().querySelector('.cl-user-button-header-title')?.textContent).toBe('Foundry'); - expect(header().querySelector('.cl-user-button-header-description')?.textContent).toBe('Pro · 24 members'); - expect(header().querySelector('.cl-user-button-header-actions')).not.toBeNull(); - }); - - it('stacks the actions under the workspace as labelled buttons', () => { - renderHeader(); - - expect(header()).toHaveAttribute('data-layout', 'stacked'); - const settings = screen.getByRole('button', { name: 'Settings' }); - expect(settings).toHaveAttribute('data-full-width'); - expect(settings).not.toHaveAttribute('data-shape', 'square'); - expect(screen.getByRole('button', { name: 'Invite' })).toHaveAttribute('data-full-width'); - expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); - }); - - it('gives each stacked label a box of its own, so a long one truncates', () => { - renderHeader(); - - const settings = screen.getByRole('button', { name: 'Settings' }); - expect(within(settings).getByText('Settings').tagName).toBe('SPAN'); - }); - - it('stacks the account actions the same way', async () => { - const onSignOutSession = vi.fn(); - const onManageAccount = vi.fn(); - renderHeader({ mode: 'user', onSignOutSession, onManageAccount }); - - expect(header()).toHaveAttribute('data-layout', 'stacked'); - await userEvent.setup().click(screen.getByRole('button', { name: 'Settings' })); - expect(onManageAccount).toHaveBeenCalled(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); - }); - - it('runs the gear inline as an icon where it is the only action', () => { - renderHeader({ hidePersonal: true, activeOrganization: null }); - - expect(header()).toHaveAttribute('data-layout', 'inline'); - expect(screen.getByRole('button', { name: 'Manage account' })).toHaveAttribute('data-shape', 'square'); - expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); - }); - - it('signs a lone account out from the header in user mode, leaving the foot to add one', async () => { - const onSignOutSession = vi.fn(); - renderHeader({ mode: 'user', additionalSessions: [], onSignOutSession }); - - expect(header()).toHaveAttribute('data-layout', 'stacked'); - expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1); - await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); - }); - - it('leads a combined surface with the account where no organization is active', async () => { - const onSignOutSession = vi.fn(); - const onManageAccount = vi.fn(); - renderHeader({ activeOrganization: null, onSignOutSession, onManageAccount }); - - expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); - expect(header()).toHaveAttribute('data-layout', 'stacked'); - await userEvent.setup().click(within(header()).getByRole('button', { name: 'Settings' })); - expect(onManageAccount).toHaveBeenCalled(); - await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); - }); - - it('leads a combined surface with the account, badged with its active organization, where asked', async () => { - const onManageAccount = vi.fn(); - const onManageOrganization = vi.fn(); - const onInviteMembers = vi.fn(); - renderHeader({ modePriority: 'user', onManageAccount, onManageOrganization, onInviteMembers }); - - expect(header().querySelector('.cl-user-button-header-title')?.textContent).toBe('Alice Smith'); - expect(header().querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F'); - await userEvent.setup().click(within(header()).getByRole('button', { name: 'Settings' })); - expect(onManageOrganization).toHaveBeenCalled(); - expect(onManageAccount).not.toHaveBeenCalled(); - await userEvent.setup().click(within(header()).getByRole('button', { name: 'Invite' })); - expect(onInviteMembers).toHaveBeenCalled(); - }); - - it('falls back to the identifier where no organization is active', () => { - renderHeader({ activeOrganization: null }); - - expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); - }); - - it('keeps the identifier on a user surface, whatever organization is active', () => { - renderHeader({ mode: 'user' }); - - expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); - }); -}); From cfce750db7239b4d00771220a636880d11c07a41 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:13:59 -0400 Subject: [PATCH 2/4] test(e2e): keep the mosaic user button e2e to what only a real app can prove --- .../src/app/custom/page.tsx | 9 +- integration/tests/mosaic/user-button.test.ts | 88 ++++--------------- 2 files changed, 17 insertions(+), 80 deletions(-) diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx index 46d6f1578b3..3656841dc26 100644 --- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx @@ -1,19 +1,12 @@ 'use client'; import { MosaicProvider, UserButton } from '@clerk/mosaic'; -import { useState } from 'react'; export default function Page() { - const [actionCount, setActionCount] = useState(0); - return ( -

custom-action-count-{actionCount}

setActionCount(count => count + 1) }, - ]} + customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]} userProfileProps={{ customPages: [{ label: 'Custom page', path: 'custom-page', content:

custom-page-content

}], }} diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index 6ad8bffd90a..37da41b9333 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -9,31 +9,23 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; - let otherFakeUser: FakeUser; - let organizations: Organization[] = []; + let organization: Organization; test.beforeAll(async () => { const u = createTestUtils({ app }); fakeUser = u.services.users.createFakeUser(test); - otherFakeUser = u.services.users.createFakeUser(test); - const [user] = await Promise.all([ - u.services.users.createBapiUser(fakeUser), - u.services.users.createBapiUser(otherFakeUser), - ]); - const suffix = Date.now(); - organizations = await Promise.all( - ['Alpha', 'Beta'].map(name => - u.services.clerk.organizations.createOrganization({ name: `Mosaic ${name} ${suffix}`, createdBy: user.id }), - ), - ); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); }); test.afterAll(async () => { const u = createTestUtils({ app }); const results = await Promise.allSettled([ - ...organizations.map(({ id }) => u.services.clerk.organizations.deleteOrganization(id)), + u.services.clerk.organizations.deleteOrganization(organization.id), fakeUser.deleteIfExists(), - otherFakeUser.deleteIfExists(), ]); await app.teardown(); const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); @@ -62,37 +54,17 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await page.getByRole('menuitem', { name: label }).click(); } - test('renders nothing while signed out', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - await u.page.goToAppHome(); - await u.page.waitForClerkJsLoaded(); - await u.po.expect.toBeSignedOut(); - - await expect(page.getByText('signed-out-state')).toBeVisible(); - await expect(trigger(page)).toHaveCount(0); - }); - - test('switches the active organization', async ({ page, context }) => { - await signIn({ page, context }); + test('switches the active organization and keeps it across a reload', async ({ page, context }) => { + const u = await signIn({ page, context }); + const { id, name } = organization; await trigger(page).click(); - await popup(page).getByRole('button', { name: 'Personal account' }).click(); - await page.waitForFunction(() => window.Clerk?.organization === null); - await page.keyboard.press('Escape'); - await expect(popup(page)).toBeHidden(); - - for (const { id, name } of organizations) { - await trigger(page).click(); - await popup(page).getByRole('button', { name }).click(); - - await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, id); - await page.keyboard.press('Escape'); - await expect(popup(page)).toBeHidden(); - } + await popup(page).getByRole('button', { name }).click(); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, id); - await trigger(page).click(); - await popup(page).getByRole('button', { name: 'Personal account' }).click(); - await page.waitForFunction(() => window.Clerk?.organization === null); + await u.page.reload(); + await u.page.waitForClerkJsLoaded(); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, id); }); test('signs out', async ({ page, context }) => { @@ -114,14 +86,9 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await page.waitForURL(url => url.pathname.startsWith('/sign-in')); }); - test('runs custom menu items', async ({ page, context }) => { + test('follows a custom menu link', async ({ page, context }) => { await signIn({ page, context }, '/custom'); - await trigger(page).click(); - await popup(page).getByRole('button', { name: 'Custom action' }).click(); - await expect(page.getByText('custom-action-count-1')).toBeVisible(); - await expect(popup(page)).toBeHidden(); - await trigger(page).click(); await popup(page).getByRole('link', { name: 'Custom link' }).click(); await expect(page.getByText('custom-link-target')).toBeVisible(); @@ -137,27 +104,4 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await page.locator('.cl-userProfile-root').getByText('Custom page').click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); - - test('switches to another signed-in account', async ({ page, context }) => { - const u = await signIn({ page, context }); - await u.po.signIn.goTo(); - await u.po.signIn.setIdentifier(otherFakeUser.email); - await u.po.signIn.continue(); - await u.po.signIn.setPassword(otherFakeUser.password); - await u.po.signIn.continue(); - await page.waitForFunction( - email => window.Clerk?.user?.primaryEmailAddress?.emailAddress === email, - otherFakeUser.email, - ); - - await u.page.goToAppHome(); - await trigger(page).click(); - await popup(page).getByRole('button', { name: 'Switch account' }).click(); - await page.getByRole('menuitem', { name: fakeUser.email }).click(); - - await page.waitForFunction( - email => window.Clerk?.user?.primaryEmailAddress?.emailAddress === email, - fakeUser.email, - ); - }); }); From 83fb51b91945132ca51e765fca32e63516dbe02a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 28 Sep 2026 14:42:44 -0400 Subject: [PATCH 3/4] test(mosaic): add a membership when the fake FAPI accepts an invitation --- packages/mosaic/src/__tests__/feature/fake-fapi.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index dcb0993b281..f622ef0d6a3 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -8,7 +8,15 @@ import type { import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; -import { fapiClient, type FapiEnvironment, fapiEnvironment, fapiPage, fapiToken } from './fapi'; +import { + fapiClient, + type FapiEnvironment, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiPage, + fapiToken, +} from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; const FAPI = 'https://clerk.abcef.12345.prod.lclclerk.com'; @@ -141,6 +149,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { } const accepted = { ...invitation, status: 'accepted' as const }; state.invitations = state.invitations.map(i => (i.id === accepted.id ? accepted : i)); + state.memberships = [...state.memberships, fapiMembership(fapiOrganization(invitation.public_organization_data))]; return envelope(accepted, state.client); }), http.post(fapiUrl('/v1/me/organization_suggestions/:id/accept'), ({ params }) => { From 612d7384ce0e3947546015ebbf46a0b84b7fd3ce Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 10:41:37 -0400 Subject: [PATCH 4/4] test(e2e): switch away from the organization sign-in lands on before switching back --- integration/tests/mosaic/user-button.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index 37da41b9333..60f625f6f3d 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -58,6 +58,12 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User const u = await signIn({ page, context }); const { id, name } = organization; + await trigger(page).click(); + await popup(page).getByRole('button', { name: 'Personal account' }).click(); + await page.waitForFunction(() => window.Clerk?.organization === null); + await page.keyboard.press('Escape'); + await expect(popup(page)).toBeHidden(); + await trigger(page).click(); await popup(page).getByRole('button', { name }).click(); await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, id);