diff --git a/.changeset/mosaic-trim-user-button-tests.md b/.changeset/mosaic-trim-user-button-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-trim-user-button-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.controller.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.controller.test.tsx deleted file mode 100644 index bd33beabdbf..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.controller.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { deferred, tick } from '../../../machines/__tests__/test-utils'; -import type { UserButtonControllerOptions, UserButtonReadyModel } from '../user-button.controller'; -import { useUserButtonController } from '../user-button.controller'; -import type { UserButtonModel } from '../user-button.model'; - -function ready(overrides: Partial = {}): UserButtonReadyModel { - return { - status: 'ready', - organizationsEnabled: true, - renderBranding: true, - activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, - activeOrganization: null, - hasOrganizations: false, - hidePersonal: false, - organizationsLoading: false, - memberships: [], - suggestions: [], - invitations: [], - additionalSessions: [], - ...overrides, - }; -} - -function Harness({ model, ...options }: { model: UserButtonModel } & UserButtonControllerOptions) { - const c = useUserButtonController(model, options); - if (c.status !== 'ready') { - return {c.status}; - } - return ( -
- {c.status} - {c.mode ?? 'combined'} - {String(c.open)} - {c.pendingKey ?? ''} - {c.activeOrganization?.organizationId ?? ''} - - - - - - {c.customMenuItems?.map(item => - item.href === undefined ? ( - - ) : ( - - {item.label} - - ), - )} -
- ); -} - -describe('useUserButtonController', () => { - it('passes loading and hidden through until the model is ready', () => { - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('hidden'); - - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('ready'); - }); - - it('forces user mode when organizations are disabled, whatever mode was asked for', () => { - const { rerender } = render( - , - ); - expect(screen.getByTestId('mode')).toHaveTextContent('user'); - - rerender( - , - ); - expect(screen.getByTestId('mode')).toHaveTextContent('organization'); - }); - - it('runs a model action through the machine and keys the affordance', async () => { - const onSelectOrganization = vi.fn(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('select-org')); - - expect(onSelectOrganization).toHaveBeenCalledWith('org_1'); - await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); - - await act(async () => { - await tick(); - }); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - expect(screen.getByTestId('pending')).toHaveTextContent(''); - }); - - it('closes immediately on a hand-off and leaves the model action to run', () => { - const onManageAccount = vi.fn(); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('manage-account')); - - expect(onManageAccount).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('closes the popover before a custom menu action runs', () => { - const onClick = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('Documentation')); - - expect(onClick).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('starts closed and opens and closes', () => { - render(); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - - fireEvent.click(screen.getByText('open')); - expect(screen.getByTestId('open')).toHaveTextContent('true'); - - fireEvent.click(screen.getByText('close')); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('holds the popup open when an action fails, even one that would have closed it', async () => { - const onSelectOrganization = vi.fn(() => Promise.reject(new Error('cannot switch'))); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('select-org')); - - await act(async () => { - await tick(); - }); - expect(screen.getByTestId('open')).toHaveTextContent('true'); - expect(screen.getByTestId('pending')).toHaveTextContent(''); - }); - - it('lets the row be clicked again after a failure', async () => { - const onSelectOrganization = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('select-org')); - await act(async () => { - await tick(); - }); - - fireEvent.click(screen.getByText('select-org')); - expect(onSelectOrganization).toHaveBeenCalledTimes(2); - - await act(async () => { - await tick(); - }); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('refuses a second action while one is in flight', async () => { - const pending = deferred(); - const onSelectOrganization = vi.fn(() => pending.promise); - const onSwitchSession = vi.fn(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('select-org')); - await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); - - fireEvent.click(screen.getByText('switch')); - expect(onSwitchSession).not.toHaveBeenCalled(); - - await act(async () => { - pending.resolve(undefined); - await tick(); - }); - }); - - it('refuses an action while the popup is closed', () => { - const onSelectOrganization = vi.fn(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByText('select-org')); - - expect(onSelectOrganization).not.toHaveBeenCalled(); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('stays closed when a dismissed action settles', async () => { - const pending = deferred(); - const onSwitchSession = vi.fn(() => pending.promise); - render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('switch')); - await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('switch:sess_2')); - - fireEvent.click(screen.getByText('close')); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - - await act(async () => { - pending.resolve(undefined); - await tick(); - }); - expect(screen.getByTestId('open')).toHaveTextContent('false'); - }); - - it('holds the surface on the model the action started from until it settles', async () => { - const pending = deferred(); - const onSwitchSession = vi.fn(() => pending.promise); - const { rerender } = render(); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByText('switch')); - - rerender( - , - ); - - expect(screen.getByTestId('active-org')).toHaveTextContent(''); - expect(screen.getByTestId('open')).toHaveTextContent('true'); - - await act(async () => { - pending.resolve(undefined); - await tick(); - }); - - expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); - expect(screen.getByTestId('open')).toHaveTextContent('true'); - }); -}); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx index 38e86877395..f810211b350 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx @@ -277,6 +277,19 @@ describe('UserButton (connected)', () => { }); }); + it('renders the fallback while Clerk is still loading', () => { + isUserLoaded = false; + renderUserButton({ fallback: }); + expect(screen.getByTestId('fallback')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Open account menu/ })).toBeNull(); + }); + + it('drops the fallback once nobody is signed in', () => { + user = null; + renderUserButton({ fallback: }); + expect(host()).toBeEmptyDOMElement(); + }); + it('renders the trigger and keeps the popover closed until clicked', () => { renderUserButton(); expect(trigger()).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts b/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts deleted file mode 100644 index 9dfe95e4a52..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveUserButtonLayout } from '../user-button.layout'; -import type { UserButtonData, UserButtonMode, UserButtonModePriority } from '../user-button.types'; - -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' } as const; - -function resolve( - mode: UserButtonMode, - data: Partial = {}, - modePriority: UserButtonModePriority = 'organization', -) { - return resolveUserButtonLayout(mode, modePriority, { - activeSession: alice, - activeOrganization: foundry, - hasOrganizations: true, - memberships: [foundry], - suggestions: [], - invitations: [], - additionalSessions: [bob], - ...data, - }); -} - -describe('resolveUserButtonLayout, where each action lands', () => { - it('spreads them across all four slots in combined mode', () => { - expect(resolve('combined').actions).toEqual({ - header: ['inviteMembers', 'manageLead'], - organizationsHeading: ['createOrganization', 'manageAccount', 'signOut'], - organizationsFooter: ['createOrganization'], - footer: ['switchAccount', 'signOutAll'], - }); - }); - - it('carries no account actions at all in organization mode', () => { - expect(resolve('organization').actions).toEqual({ - header: ['inviteMembers', 'manageLead'], - organizationsHeading: [], - organizationsFooter: ['createOrganization'], - footer: [], - }); - }); - - it('takes the account actions into the header and the foot in user mode', () => { - expect(resolve('user').actions).toEqual({ - header: ['signOut', 'manageLead'], - organizationsHeading: [], - organizationsFooter: [], - footer: ['switchAccount', 'signOutAll'], - }); - }); -}); - -describe('resolveUserButtonLayout, what the data settles', () => { - it('offers no invitation where no organization is active', () => { - expect(resolve('combined', { activeOrganization: null }).actions.header).toEqual(['manageLead']); - }); - - // With no second account the flyout would open onto one row, so the foot offers that row instead. - // "All accounts" is that one account too, and the account's own row already signs out of it. - it.each(['combined', 'user'])( - 'leaves the foot "Add account" alone in %s mode where there is one account', - mode => { - expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount']); - }, - ); -}); - -describe('resolveUserButtonLayout, which sections render', () => { - it('heads the organizations with the account even where it belongs to none', () => { - const layout = resolve('combined', { hasOrganizations: false, memberships: [], activeOrganization: null }); - - expect(layout.showOrganizationsHeading).toBe(true); - expect(layout.showOrganizations).toBe(false); - }); - - it('counts an invitation or a suggestion as something to list', () => { - const invitation = { - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_2', - organizationName: 'Other Co', - status: 'pending', - } as const; - const data = { hasOrganizations: false, memberships: [], activeOrganization: null }; - - expect(resolve('combined', data).showOrganizations).toBe(false); - expect(resolve('combined', { ...data, invitations: [invitation] }).showOrganizations).toBe(true); - }); - - it('carries no organizations in user mode', () => { - expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); - }); -}); - -describe('resolveUserButtonLayout, what the surface leads with', () => { - it('takes the priority only where there are two things to choose between', () => { - expect(resolve('combined', {}, 'user').leadWith).toBe('user'); - expect(resolve('combined', {}, 'organization').leadWith).toBe('organization'); - expect(resolve('organization', {}, 'user').leadWith).toBe('organization'); - expect(resolve('user', {}, 'organization').leadWith).toBe('user'); - }); -}); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.test.tsx deleted file mode 100644 index f0975997677..00000000000 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { UserButton } from '../user-button'; -import type { UserButtonController } from '../user-button.controller'; - -let controller: UserButtonController; - -const { useUserButtonModel, useCustomPages } = vi.hoisted(() => ({ - useUserButtonModel: vi.fn(() => ({ status: 'loading' })), - useCustomPages: vi.fn(), -})); - -vi.mock('../user-button.model', () => ({ useUserButtonModel })); - -vi.mock('../user-button.controller', () => ({ - useUserButtonController: () => controller, -})); - -// The two bridges are told apart by the built-in page list each was given. -vi.mock('../user-button.pages', () => ({ - useUserProfilePages: () => ['account'], - useOrganizationProfilePages: () => ['general'], - useCustomPages, -})); - -// The wrapper's own job is which of the three controller states renders what, so the surface is -// stubbed out and the view's own tests cover it. -vi.mock('../user-button.view', () => ({ - UserButtonView: () => , -})); - -function ready(): UserButtonController { - return { - status: 'ready', - renderBranding: true, - activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, - activeOrganization: null, - hasOrganizations: false, - hidePersonal: false, - organizationsLoading: false, - memberships: [], - suggestions: [], - invitations: [], - additionalSessions: [], - }; -} - -describe('UserButton', () => { - beforeEach(() => { - controller = { status: 'loading' }; - useUserButtonModel.mockClear(); - useCustomPages.mockImplementation(({ builtInPages }: { builtInPages: readonly string[] }) => ({ - customPages: [{ label: `${builtInPages[0]}-page` }], - portals: [ - , - ], - })); - }); - - it('stands the fallback in while Clerk is still answering', () => { - render(} />); - expect(screen.getByTestId('fallback')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); - - // Signing out is an answer, not a wait. Holding the placeholder there would promise a button to - // someone who is never going to get one. - it('drops the fallback once nobody is signed in', () => { - controller = { status: 'hidden' }; - render(} />); - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); - - it('renders the surface once the session is ready', () => { - controller = ready(); - render(} />); - expect(screen.getByTestId('view')).toBeInTheDocument(); - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - }); - - it('renders no fallback while loading when none is given', () => { - render(); - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); - - it('keeps the custom page portals of both profiles mounted in every state', () => { - const { rerender } = render(); - expect(screen.getByTestId('account-portal')).toBeInTheDocument(); - expect(screen.getByTestId('general-portal')).toBeInTheDocument(); - - controller = { status: 'hidden' }; - rerender(); - expect(screen.getByTestId('account-portal')).toBeInTheDocument(); - expect(screen.getByTestId('general-portal')).toBeInTheDocument(); - - controller = ready(); - rerender(); - expect(screen.getByTestId('account-portal')).toBeInTheDocument(); - expect(screen.getByTestId('general-portal')).toBeInTheDocument(); - }); - - it('hands the model each profile modal its props, and keeps the routing options apart', () => { - const appearance = { variables: { colorPrimary: 'red' } }; - const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] }; - const apiKeysProps = { showDescription: true }; - render( - , - ); - - expect(useUserButtonModel).toHaveBeenCalledWith( - { afterLeaveOrganizationUrl: '/left' }, - { - userProfile: { customPages: [{ label: 'account-page' }], additionalOAuthScopes, apiKeysProps, appearance }, - organizationProfile: { customPages: [{ label: 'general-page' }], appearance }, - }, - ); - }); - - it('bridges each profile its own custom pages, ordered against its own built-in pages', () => { - const page = { label: 'Usage', path: 'usage', content:

Usage

}; - render( - , - ); - - expect(useCustomPages).toHaveBeenCalledWith({ - items: [page], - order: ['usage', 'account'], - builtInPages: ['account'], - }); - expect(useCustomPages).toHaveBeenCalledWith({ - items: [page], - order: ['members', 'usage'], - builtInPages: ['general'], - }); - }); -});