diff --git a/.changeset/mosaic-user-button-e2e.md b/.changeset/mosaic-user-button-e2e.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-e2e.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/tests/mosaic-user-button.test.ts b/integration/tests/mosaic-user-button.test.ts new file mode 100644 index 00000000000..2baa2066d90 --- /dev/null +++ b/integration/tests/mosaic-user-button.test.ts @@ -0,0 +1,450 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import path from 'node:path'; + +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import type { FakeOrganization, FakeUserWithEmail } from '../testUtils'; +import { createTestUtils } from '../testUtils'; + +/** + * E2E coverage for the experimental Mosaic `` exported from + * `@clerk/nextjs/experimental/mosaic`. + * + * `packages/ui/src/mosaic/user-button/__tests__/` already covers the component against a mocked + * Clerk: every rendered surface, every mapping, and every outbound call with its exact arguments. + * None of that is repeated here. This suite asserts only what a mock cannot observe — whether Clerk + * acted on the call, whether the server sees the result, and whether clerk-js renders into the DOM + * the Mosaic tree hands it — plus the coexistence the migration depends on: legacy Emotion + * components hotloaded from clerk-js running beside StyleX components bundled into the SDK. + */ + +// The template's layout, plus the one line this suite adds. Its appearance options are what the +// shared sign-in page object's selectors depend on, so they are carried over verbatim. +const layout = () => `import './globals.css'; +import '@clerk/nextjs/experimental/mosaic/styles.css'; +import { Inter } from 'next/font/google'; +import { ClerkProvider } from '@clerk/nextjs'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Mosaic UserButton', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +}`; + +// Both UserButtons mount together while signed in, so every case below runs with the legacy Emotion +// tree and the Mosaic tree live at once rather than proving coexistence in one isolated smoke test. +const mosaicPage = () => `'use client'; +import { UserButton } from '@clerk/nextjs/experimental/mosaic'; +import { SignIn, Show, UserButton as LegacyUserButton } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ + Mosaic custom page

, + }, + ], + }} + /> + +
+ + + +
+ ); +}`; + +// `pageOrder` names one custom page and nothing else, so `useCustomPages` sends every built-in +// behind it. clerk-js puts any page it was not asked to move ahead of everything it was, which is +// what makes the nav order readable as an assertion about the built-in list itself. +const pageOrderPage = () => `'use client'; +import { UserButton } from '@clerk/nextjs/experimental/mosaic'; + +export default function Page() { + return ( + Mosaic custom page

, + }, + ], + pageOrder: ['usage'], + }} + /> + ); +}`; + +// A URL is the whole opt-in to navigation, so this passes one and nothing else. +const routedPage = () => `'use client'; +import { UserButton } from '@clerk/nextjs/experimental/mosaic'; + +export default function Page() { + return ; +}`; + +const accountPage = () => `export default function Page() { + return

Routed account page

; +}`; + +// The shared template's route answers with `userId` alone; the organization assertions need the +// rest of the auth object. +const meRoute = () => `import { auth } from '@clerk/nextjs/server'; + +export async function GET() { + const { userId, orgId, orgRole, orgSlug } = await auth(); + return Response.json({ + userId: userId ?? null, + orgId: orgId ?? null, + orgRole: orgRole ?? null, + orgSlug: orgSlug ?? null, + }); +}`; + +test.describe('Mosaic UserButton @nextjs', () => { + test.describe.configure({ mode: 'serial' }); + + let app: Application; + let fakeUser: FakeUserWithEmail; + let otherUser: FakeUserWithEmail; + let fakeOrganization: FakeOrganization; + // Filled as each resource is created, so a setup that fails halfway still tears down what it made. + const cleanup: (() => Promise)[] = []; + + test.beforeAll(async () => { + // Installing and booting a fresh app, before a single test runs. + test.setTimeout(90_000); + + app = await appConfigs.next.appRouter + .clone() + // Deliberately no `.addDependency('@clerk/ui', PKGLAB)`, unlike composed-components.test.ts. + // The Mosaic entry is bundled into @clerk/react at build time, so @clerk/nextjs alone has to + // be enough. The absence of that line is what the last test in this file asserts. + .addFile('src/app/layout.tsx', layout) + .addFile('src/app/mosaic/page.tsx', mosaicPage) + .addFile('src/app/mosaic-page-order/page.tsx', pageOrderPage) + .addFile('src/app/mosaic-routed/page.tsx', routedPage) + .addFile('src/app/account/page.tsx', accountPage) + .addFile('src/app/api/me/route.ts', meRoute) + .commit(); + cleanup.push(() => app.teardown()); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const m = createTestUtils({ app }); + // Every account here is switched to by its email, so the optional field has to be there. + const createUser = (): FakeUserWithEmail => { + const user = m.services.users.createFakeUser(); + if (!user.email) { + throw new Error('createFakeUser produced no email address'); + } + return { ...user, email: user.email }; + }; + + fakeUser = createUser(); + otherUser = createUser(); + const user = await m.services.users.createBapiUser(fakeUser); + cleanup.unshift(() => fakeUser.deleteIfExists()); + await m.services.users.createBapiUser(otherUser); + cleanup.unshift(() => otherUser.deleteIfExists()); + fakeOrganization = await m.services.users.createFakeOrganization(user.id); + cleanup.unshift(() => fakeOrganization.delete()); + }); + + test.afterAll(async () => { + test.setTimeout(90_000); + for (const teardown of cleanup) { + await teardown(); + } + }); + + type AuthState = { + userId: string | null; + orgId: string | null; + orgRole: string | null; + orgSlug: string | null; + }; + + /** + * What the server makes of the session cookie the browser is currently holding. The mocked vitest + * suite can see the `setActive` argument; only this can see whether Clerk acted on it. + */ + const readAuthState = async (page: Page): Promise => { + const res = await page.request.get(new URL('/api/me', app.serverUrl).toString()); + expect(res.status()).toBe(200); + const { userId, orgId, orgRole, orgSlug } = await res.json(); + return { userId, orgId, orgRole, orgSlug }; + }; + + test('mounts beside the legacy UserButton, each popover opening on its own', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic'); + await u.po.mosaicUserButton.waitForMounted(); + await u.po.userButton.waitForMounted(); + + // The Mosaic popover opens without the legacy one following it, and vice versa. Two Floating UI + // copies and two style systems in one tree. + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await expect(page.locator('.cl-userButtonPopoverCard')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await u.po.mosaicUserButton.waitForPopoverClosed(); + + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + await expect(u.po.mosaicUserButton.popup()).toHaveCount(0); + }); + + test('selecting the personal workspace, then the organization, reaches the server', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic'); + await u.po.mosaicUserButton.waitForMounted(); + + // The only organization is already active on sign-in, so its row is the current item rather than + // a button. Personal is the reachable move first; the organization becomes selectable after it. + await u.po.mosaicUserButton.expectTriggerLabel(fakeOrganization.name); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.selectPersonalWorkspace(); + await u.po.mosaicUserButton.waitForPopoverClosed(); + + await expect(async () => { + expect((await readAuthState(page)).orgId).toBeNull(); + }).toPass(); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.selectWorkspace(fakeOrganization.name); + await u.po.mosaicUserButton.waitForPopoverClosed(); + await u.po.mosaicUserButton.expectTriggerLabel(fakeOrganization.name); + + await expect(async () => { + const auth = await readAuthState(page); + expect(auth.orgId).toBe(fakeOrganization.organization.id); + expect(auth.orgRole).toBe('org:admin'); + expect(auth.orgSlug).toBeTruthy(); + }).toPass(); + }); + + test('switching accounts changes the session the server answers for', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Second sign-in adds a session rather than replacing it. + await u.po.signIn.goTo(); + await u.po.signIn.setIdentifier(otherUser.email); + await u.po.signIn.continue(); + await u.po.signIn.setPassword(otherUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + // Avoid backend rate-limiting on session touch. + await new Promise(resolve => setTimeout(resolve, 3000)); + + await u.page.goToRelative('/mosaic'); + await u.po.mosaicUserButton.waitForMounted(); + const before = await readAuthState(page); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.switchAccount(fakeUser.email); + + await expect(async () => { + const after = await readAuthState(page); + expect(after.userId).toBeTruthy(); + expect(after.userId).not.toBe(before.userId); + }).toPass(); + }); + + test('signing out of every account hands the page back to the legacy SignIn', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic'); + await u.po.mosaicUserButton.waitForMounted(); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.triggerSignOutAll(); + + // `afterSignOutUrl` points back at this page, so what mounts is the legacy `` inside + // ``: a clerk-js component reacting to state a Mosaic component changed. + await u.po.signIn.waitForMounted(); + await expect(async () => { + expect((await readAuthState(page)).userId).toBeNull(); + }).toPass(); + }); + + test('managing the account opens the clerk-js profile, with a custom page inside it', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic'); + await u.po.mosaicUserButton.waitForMounted(); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.triggerManageAccount(fakeUser.email); + + // The modal is rendered by clerk-js, so the legacy page object is the right tool. Reaching it + // from a Mosaic surface is the hand-off under test. + await u.po.userProfile.waitForUserProfileModal(); + await u.po.userProfile.waitForMounted(); + + // The Mosaic tree portals this node into a container clerk-js owns. + await page.getByRole('button', { name: 'Usage' }).click(); + await expect(page.getByTestId('mosaic-custom-page')).toBeVisible(); + }); + + /** + * `useUserProfilePages` hardcodes the profile's own page list and says so: it mirrors clerk-js + * rather than reading from it, because the profile is not mounted at the point the order has to be + * decided. Nothing else at any level asserts the mirror is accurate — the vitest suite checks the + * order is forwarded, against a list it also supplies. This is the only place the two can be + * compared, and it catches drift in both directions without knowing the instance's configuration: + * + * - a built-in the mirror does not name is one clerk-js was not asked to move, so it lands ahead + * of the custom page; + * - a built-in the mirror names but clerk-js does not have fails `isValidPageItem`, is dropped, + * and logs `Invalid custom page data`. + */ + test('orders the profile around a custom page, so the built-in list has to match clerk-js', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + const invalidPageErrors: string[] = []; + page.on('console', message => { + if (message.type() === 'error' && message.text().includes('Invalid custom page data')) { + invalidPageErrors.push(message.text()); + } + }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic-page-order'); + await u.po.mosaicUserButton.waitForMounted(); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.triggerManageAccount(fakeUser.email); + await u.po.userProfile.waitForUserProfileModal(); + await u.po.userProfile.waitForMounted(); + + // Only the named page leads. Anything else here is a page the profile brings that the mirror + // does not know about, so ordering a custom page after a built-in would silently misplace it. + const navItems = page.locator('.cl-navbarButton'); + await expect(navItems.first()).toHaveText('Usage'); + // More than the custom page, or the built-ins were all dropped and there is nothing to order. + expect(await navItems.count()).toBeGreaterThan(1); + + expect(invalidPageErrors, 'the mirror named a page clerk-js does not have').toEqual([]); + }); + + /** + * `openOrNavigate` decides between a modal and a route for all three profile surfaces, and it is + * the widest configuration branch in the controller. The vitest suite covers every combination by + * asserting `navigate` was called with a string. Nothing asserts the app actually lands there. + */ + test('a userProfileUrl routes to the app page instead of opening the modal', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await context.clearCookies(); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/mosaic-routed'); + await u.po.mosaicUserButton.waitForMounted(); + + await u.po.mosaicUserButton.toggleTrigger(); + await u.po.mosaicUserButton.waitForPopover(); + await u.po.mosaicUserButton.triggerManageAccount(fakeUser.email); + + await page.waitForURL(new URL('/account', app.serverUrl).toString()); + await expect(page.getByRole('heading', { name: 'Routed account page' })).toBeVisible(); + + // Routing and the modal are the two halves of one decision, so the modal not opening is half the + // assertion. The popover goes with it: it would otherwise sit over the page it just opened. + await expect(page.locator('.cl-modalContent')).toHaveCount(0); + await u.po.mosaicUserButton.waitForPopoverClosed(); + }); + + // The Mosaic bundle is inlined into @clerk/react at build time. If it ever regresses to importing + // @clerk/ui at runtime, every test above still passes here in the monorepo — where @clerk/ui is + // always resolvable — and breaks only for consumers. This is the check that would catch it. + test('reaches the mosaic entry without @clerk/ui anywhere in the app', async () => { + // pnpm resolves `@clerk/react` beside `@clerk/nextjs` in the store rather than hoisting it to + // the app's own `node_modules`, so it is reached through the installed package, not by name. + const nextjsDir = realpathSync(path.join(app.appDir, 'node_modules', '@clerk', 'nextjs')); + const dependenciesOf = (manifest: string): string[] => { + expect(existsSync(manifest), `${manifest} is not installed in the test app`).toBe(true); + return Object.keys(JSON.parse(readFileSync(manifest, 'utf-8')).dependencies ?? {}); + }; + + expect(existsSync(path.join(app.appDir, 'node_modules', '@clerk', 'ui'))).toBe(false); + expect(dependenciesOf(path.join(nextjsDir, 'package.json'))).not.toContain('@clerk/ui'); + expect(dependenciesOf(path.join(nextjsDir, '..', 'react', 'package.json'))).not.toContain('@clerk/ui'); + }); +}); diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index 67c6bb73ec3..5eb601ee095 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -7,6 +7,7 @@ import { createClerkPageObject } from './clerk'; import { createExpectPageObject } from './expect'; import { createImpersonationPageObject } from './impersonation'; import { createKeylessPopoverPageObject } from './keylessPopover'; +import { createMosaicUserButtonPageObject } from './mosaicUserButton'; import { createOrganizationProfileComponentPageObject } from './organizationProfile'; import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcher'; import { createPlanDetailsPageObject } from './planDetails'; @@ -50,6 +51,7 @@ export const createPageObjects = ({ testingToken: createTestingTokenPageObject(testArgs), userAvatar: createUserAvatarPageObject(testArgs), userButton: createUserButtonPageObject(testArgs), + mosaicUserButton: createMosaicUserButtonPageObject(testArgs), userProfile: createUserProfileComponentPageObject(testArgs), userVerification: createUserVerificationComponentPageObject(testArgs), waitlist: createWaitlistComponentPageObject(testArgs), diff --git a/packages/testing/src/playwright/unstable/page-objects/mosaicUserButton.ts b/packages/testing/src/playwright/unstable/page-objects/mosaicUserButton.ts new file mode 100644 index 00000000000..42443ecd377 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/mosaicUserButton.ts @@ -0,0 +1,53 @@ +import { expect } from '@playwright/test'; + +import type { EnhancedPage } from './app'; + +/** + * The Mosaic UserButton renders no `data-testid` and no component-scoped classnames, so everything + * here goes through roles and accessible names. The popup and every menu portal out of the trigger's + * tree, which is why they are queried from the page rather than from within the trigger. + */ +export const createMosaicUserButtonPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + + const trigger = () => page.getByRole('button', { name: /^Open account menu for / }); + // Exact, because clerk-js labels the legacy popover "Account panel" and role names match on substring. + const popup = () => page.getByRole('dialog', { name: 'Account', exact: true }); + + const self = { + trigger, + popup, + waitForMounted: () => trigger().waitFor({ state: 'attached' }), + toggleTrigger: () => trigger().click(), + waitForPopover: () => popup().waitFor({ state: 'visible' }), + waitForPopoverClosed: () => popup().waitFor({ state: 'detached' }), + expectTriggerLabel: (workspaceName: string) => { + return expect(trigger()).toHaveAccessibleName(`Open account menu for ${workspaceName}`); + }, + /** Switches to an organization by the name it lists. */ + selectWorkspace: (name: string) => popup().getByRole('button', { name, exact: true }).click(), + selectPersonalWorkspace: () => popup().getByRole('button', { name: 'Personal account', exact: true }).click(), + /** Switches to another signed-in account, which lists by its identifier. */ + switchAccount: (identifier: string) => popup().getByRole('button', { name: identifier, exact: true }).click(), + /** The `⋯` on an account row, holding manage-account, create-organization, and sign-out. */ + openAccountActions: (identifier: string) => { + return popup() + .getByRole('button', { name: `Actions for ${identifier}` }) + .click(); + }, + /** The `⋯` beside the "Accounts" heading, holding add-account. */ + openAccountsHeadingActions: () => popup().getByRole('button', { name: 'Account actions' }).click(), + clickMenuItem: (name: string) => page.getByRole('menuitem', { name, exact: true }).click(), + /** Header and foot actions alike; each name appears once in the popup. */ + clickAction: (name: string) => popup().getByRole('button', { name, exact: true }).click(), + /** Manage account is only reachable through the account row's actions menu. */ + triggerManageAccount: async (identifier: string) => { + await self.openAccountActions(identifier); + await self.clickMenuItem('Manage account'); + }, + triggerManageOrganization: () => self.clickAction('Manage organization'), + triggerSignOutAll: () => self.clickAction('Sign out of all accounts'), + }; + + return self; +};