@@ -82,6 +82,41 @@ export default function Page() {
8282 );
8383}` ;
8484
85+ // `pageOrder` names one custom page and nothing else, so `useCustomPages` sends every built-in
86+ // behind it. clerk-js puts any page it was not asked to move ahead of everything it was, which is
87+ // what makes the nav order readable as an assertion about the built-in list itself.
88+ const pageOrderPage = ( ) => `'use client';
89+ import { UserButton } from '@clerk/nextjs/experimental/mosaic';
90+
91+ export default function Page() {
92+ return (
93+ <UserButton
94+ userProfileProps={{
95+ customPages: [
96+ {
97+ path: 'usage',
98+ label: 'Usage',
99+ content: <p data-testid='mosaic-custom-page'>Mosaic custom page</p>,
100+ },
101+ ],
102+ pageOrder: ['usage'],
103+ }}
104+ />
105+ );
106+ }` ;
107+
108+ // A URL is the whole opt-in to navigation, so this passes one and nothing else.
109+ const routedPage = ( ) => `'use client';
110+ import { UserButton } from '@clerk/nextjs/experimental/mosaic';
111+
112+ export default function Page() {
113+ return <UserButton userProfileUrl='/account' />;
114+ }` ;
115+
116+ const accountPage = ( ) => `export default function Page() {
117+ return <h1>Routed account page</h1>;
118+ }` ;
119+
85120// The shared template's route answers with `userId` alone; the organization assertions need the
86121// rest of the auth object.
87122const meRoute = ( ) => `import { auth } from '@clerk/nextjs/server';
@@ -117,6 +152,9 @@ test.describe('Mosaic UserButton @nextjs', () => {
117152 // be enough. The absence of that line is what the last test in this file asserts.
118153 . addFile ( 'src/app/layout.tsx' , layout )
119154 . addFile ( 'src/app/mosaic/page.tsx' , mosaicPage )
155+ . addFile ( 'src/app/mosaic-page-order/page.tsx' , pageOrderPage )
156+ . addFile ( 'src/app/mosaic-routed/page.tsx' , routedPage )
157+ . addFile ( 'src/app/account/page.tsx' , accountPage )
120158 . addFile ( 'src/app/api/me/route.ts' , meRoute )
121159 . commit ( ) ;
122160 cleanup . push ( ( ) => app . teardown ( ) ) ;
@@ -315,6 +353,84 @@ test.describe('Mosaic UserButton @nextjs', () => {
315353 await expect ( page . getByTestId ( 'mosaic-custom-page' ) ) . toBeVisible ( ) ;
316354 } ) ;
317355
356+ /**
357+ * `useUserProfilePages` hardcodes the profile's own page list and says so: it mirrors clerk-js
358+ * rather than reading from it, because the profile is not mounted at the point the order has to be
359+ * decided. Nothing else at any level asserts the mirror is accurate — the vitest suite checks the
360+ * order is forwarded, against a list it also supplies. This is the only place the two can be
361+ * compared, and it catches drift in both directions without knowing the instance's configuration:
362+ *
363+ * - a built-in the mirror does not name is one clerk-js was not asked to move, so it lands ahead
364+ * of the custom page;
365+ * - a built-in the mirror names but clerk-js does not have fails `isValidPageItem`, is dropped,
366+ * and logs `Invalid custom page data`.
367+ */
368+ test ( 'orders the profile around a custom page, so the built-in list has to match clerk-js' , async ( {
369+ page,
370+ context,
371+ } ) => {
372+ const u = createTestUtils ( { app, page, context } ) ;
373+ await context . clearCookies ( ) ;
374+
375+ const invalidPageErrors : string [ ] = [ ] ;
376+ page . on ( 'console' , message => {
377+ if ( message . type ( ) === 'error' && message . text ( ) . includes ( 'Invalid custom page data' ) ) {
378+ invalidPageErrors . push ( message . text ( ) ) ;
379+ }
380+ } ) ;
381+
382+ await u . po . signIn . goTo ( ) ;
383+ await u . po . signIn . signInWithEmailAndInstantPassword ( { email : fakeUser . email , password : fakeUser . password } ) ;
384+ await u . po . expect . toBeSignedIn ( ) ;
385+
386+ await u . page . goToRelative ( '/mosaic-page-order' ) ;
387+ await u . po . mosaicUserButton . waitForMounted ( ) ;
388+
389+ await u . po . mosaicUserButton . toggleTrigger ( ) ;
390+ await u . po . mosaicUserButton . waitForPopover ( ) ;
391+ await u . po . mosaicUserButton . triggerManageAccount ( fakeUser . email ) ;
392+ await u . po . userProfile . waitForUserProfileModal ( ) ;
393+ await u . po . userProfile . waitForMounted ( ) ;
394+
395+ // Only the named page leads. Anything else here is a page the profile brings that the mirror
396+ // does not know about, so ordering a custom page after a built-in would silently misplace it.
397+ const navItems = page . locator ( '.cl-navbarButton' ) ;
398+ await expect ( navItems . first ( ) ) . toHaveText ( 'Usage' ) ;
399+ // More than the custom page, or the built-ins were all dropped and there is nothing to order.
400+ expect ( await navItems . count ( ) ) . toBeGreaterThan ( 1 ) ;
401+
402+ expect ( invalidPageErrors , 'the mirror named a page clerk-js does not have' ) . toEqual ( [ ] ) ;
403+ } ) ;
404+
405+ /**
406+ * `openOrNavigate` decides between a modal and a route for all three profile surfaces, and it is
407+ * the widest configuration branch in the controller. The vitest suite covers every combination by
408+ * asserting `navigate` was called with a string. Nothing asserts the app actually lands there.
409+ */
410+ test ( 'a userProfileUrl routes to the app page instead of opening the modal' , async ( { page, context } ) => {
411+ const u = createTestUtils ( { app, page, context } ) ;
412+ await context . clearCookies ( ) ;
413+
414+ await u . po . signIn . goTo ( ) ;
415+ await u . po . signIn . signInWithEmailAndInstantPassword ( { email : fakeUser . email , password : fakeUser . password } ) ;
416+ await u . po . expect . toBeSignedIn ( ) ;
417+
418+ await u . page . goToRelative ( '/mosaic-routed' ) ;
419+ await u . po . mosaicUserButton . waitForMounted ( ) ;
420+
421+ await u . po . mosaicUserButton . toggleTrigger ( ) ;
422+ await u . po . mosaicUserButton . waitForPopover ( ) ;
423+ await u . po . mosaicUserButton . triggerManageAccount ( fakeUser . email ) ;
424+
425+ await page . waitForURL ( new URL ( '/account' , app . serverUrl ) . toString ( ) ) ;
426+ await expect ( page . getByRole ( 'heading' , { name : 'Routed account page' } ) ) . toBeVisible ( ) ;
427+
428+ // Routing and the modal are the two halves of one decision, so the modal not opening is half the
429+ // assertion. The popover goes with it: it would otherwise sit over the page it just opened.
430+ await expect ( page . locator ( '.cl-modalContent' ) ) . toHaveCount ( 0 ) ;
431+ await u . po . mosaicUserButton . waitForPopoverClosed ( ) ;
432+ } ) ;
433+
318434 // The Mosaic bundle is inlined into @clerk /react at build time. If it ever regresses to importing
319435 // @clerk /ui at runtime, every test above still passes here in the monorepo — where @clerk/ui is
320436 // always resolvable — and breaks only for consumers. This is the check that would catch it.
0 commit comments