diff --git a/.changeset/guard-integration-app-boot-hooks.md b/.changeset/guard-integration-app-boot-hooks.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/guard-integration-app-boot-hooks.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 38fcec32813..8d7c82362e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -380,6 +380,7 @@ jobs:
test-name:
[
'generic',
+ 'composed',
'express',
'fastify',
'ap-flows',
diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml
index fb421178e39..3d7afd5f3af 100644
--- a/.github/workflows/e2e-staging.yml
+++ b/.github/workflows/e2e-staging.yml
@@ -142,6 +142,7 @@ jobs:
- 'sessions:staging'
- 'handshake:staging'
- 'generic'
+ - 'composed'
- 'cache-components'
- 'express'
- 'hono'
diff --git a/integration/models/application.ts b/integration/models/application.ts
index b8ca49851e0..7c8067bb192 100644
--- a/integration/models/application.ts
+++ b/integration/models/application.ts
@@ -142,6 +142,8 @@ export const application = (
},
});
+ cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL'));
+
const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0;
await waitForServer(runtimeServerUrl, {
log,
@@ -150,7 +152,6 @@ export const application = (
acceptAnyResponse: opts.acceptAnyResponse,
});
log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`);
- cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL'));
state.serverUrl = runtimeServerUrl;
// Setup Clerk testing tokens after the server is running
@@ -253,6 +254,8 @@ export const application = (
},
});
+ cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL'));
+
if (opts.detached) {
const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0;
await waitForServer(runtimeServerUrl, { log, maxAttempts: Infinity, shouldExit });
@@ -261,7 +264,6 @@ export const application = (
}
log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`);
- cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL'));
state.serverUrl = runtimeServerUrl;
return { port, serverUrl: runtimeServerUrl, pid: proc.pid };
},
diff --git a/integration/templates/next-app-router-bundled-ui/src/app/composed/ensure-active-organization.tsx b/integration/templates/next-app-router-bundled-ui/src/app/composed/ensure-active-organization.tsx
new file mode 100644
index 00000000000..3190dea8c82
--- /dev/null
+++ b/integration/templates/next-app-router-bundled-ui/src/app/composed/ensure-active-organization.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
+import { useEffect } from 'react';
+
+export function EnsureActiveOrganization() {
+ const { setActive } = useClerk();
+ const { organization } = useOrganization();
+ const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
+
+ useEffect(() => {
+ if (!isLoaded || organization) return;
+ const first = userMemberships?.data?.[0]?.organization;
+ if (first && setActive) {
+ void setActive({ organization: first.id });
+ }
+ }, [isLoaded, organization, userMemberships, setActive]);
+
+ return null;
+}
diff --git a/integration/templates/next-app-router-bundled-ui/src/app/composed/organization-security/page.tsx b/integration/templates/next-app-router-bundled-ui/src/app/composed/organization-security/page.tsx
new file mode 100644
index 00000000000..108222f7e05
--- /dev/null
+++ b/integration/templates/next-app-router-bundled-ui/src/app/composed/organization-security/page.tsx
@@ -0,0 +1,14 @@
+import { OrganizationProfileProvider, OrganizationProfileSecurityPanel } from '@clerk/ui/experimental';
+
+import { EnsureActiveOrganization } from '../ensure-active-organization';
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/integration/templates/next-app-router-bundled-ui/src/app/composed/organization/page.tsx b/integration/templates/next-app-router-bundled-ui/src/app/composed/organization/page.tsx
new file mode 100644
index 00000000000..67df0df4601
--- /dev/null
+++ b/integration/templates/next-app-router-bundled-ui/src/app/composed/organization/page.tsx
@@ -0,0 +1,24 @@
+import {
+ OrganizationProfileDeleteSection,
+ OrganizationProfileGeneralPanel,
+ OrganizationProfileLeaveSection,
+ OrganizationProfileProfileSection,
+ OrganizationProfileProvider,
+} from '@clerk/ui/experimental';
+
+import { EnsureActiveOrganization } from '../ensure-active-organization';
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/integration/templates/next-app-router-bundled-ui/src/app/composed/user/page.tsx b/integration/templates/next-app-router-bundled-ui/src/app/composed/user/page.tsx
new file mode 100644
index 00000000000..2e169cbdfa8
--- /dev/null
+++ b/integration/templates/next-app-router-bundled-ui/src/app/composed/user/page.tsx
@@ -0,0 +1,30 @@
+import {
+ UserProfileAccountPanel,
+ UserProfileDeleteSection,
+ UserProfileEmailSection,
+ UserProfileMfaSection,
+ UserProfilePasswordSection,
+ UserProfilePhoneSection,
+ UserProfileProfileSection,
+ UserProfileProvider,
+ UserProfileSecurityPanel,
+ UserProfileUsernameSection,
+} from '@clerk/ui/experimental';
+
+export default function Page() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/integration/templates/next-app-router-bundled-ui/src/app/themes/page.tsx b/integration/templates/next-app-router-bundled-ui/src/app/themes/page.tsx
index a1707768122..2ddf8de8908 100644
--- a/integration/templates/next-app-router-bundled-ui/src/app/themes/page.tsx
+++ b/integration/templates/next-app-router-bundled-ui/src/app/themes/page.tsx
@@ -9,7 +9,7 @@ export default function ThemesPage() {
Dark
Loading dark theme>}
/>
@@ -17,7 +17,7 @@ export default function ThemesPage() {
Neobrutalism
Loading neobrutalism theme>}
/>
@@ -25,7 +25,7 @@ export default function ThemesPage() {
Shades of Purple
Loading shadesOfPurple theme>}
/>
@@ -33,7 +33,7 @@ export default function ThemesPage() {
Shadcn
Loading shadcn theme>}
/>
diff --git a/integration/tests/composed-components.test.ts b/integration/tests/composed-components.test.ts
deleted file mode 100644
index 515993a8eb5..00000000000
--- a/integration/tests/composed-components.test.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-import { type BrowserContext, expect, type Page, test } from '@playwright/test';
-
-import type { Application } from '../models/application';
-import { appConfigs } from '../presets';
-import { PKGLAB } from '../presets/utils';
-import type { FakeOrganization, FakeUser } from '../testUtils';
-import { createTestUtils } from '../testUtils';
-import { stringPhoneNumber } from '../testUtils/phoneUtils';
-
-/**
- * E2E parity coverage for the experimental composed exports from `@clerk/ui/experimental`.
- *
- * The composed API (`UserProfileProvider`, `UserProfileAccountPanel`, `UserProfileEmailSection`,
- * ...) lets a developer compose the profile UI from flat exports. Its section wrappers render the
- * exact same underlying components as the standard `` (e.g. `UserProfileEmailSection`
- * -> `AccountEmails` from `AccountSections`), so the trusted `user-profile.test.ts` flows apply
- * unchanged. This suite reuses the shared `@clerk/testing` page-object step helpers (`u.po.userProfile.*`)
- * and the same assertions as `user-profile.test.ts`, so if a composed export diverges from the
- * standard behavior an existing, trusted assertion fails.
- *
- * `user-profile.test.ts` is intentionally left untouched while this API is experimental. The only
- * differences handled locally here are the composed component's lack of chrome:
- * - it does not render the `.cl-userProfile-root` wrapper, so the mount wait keys off a rendered
- * affordance instead;
- * - it has no Account/Security tabs — the page renders both panels, so security sections are
- * already present (no `switchToSecurityTab()`).
- */
-
-// A page composed from the experimental section exports, mirroring the sections the standard
-// shows across its Account and Security tabs.
-const composedUserProfilePage = () => `'use client';
-import {
- UserProfileProvider,
- UserProfileAccountPanel,
- UserProfileProfileSection,
- UserProfileUsernameSection,
- UserProfileEmailSection,
- UserProfilePhoneSection,
- UserProfileSecurityPanel,
- UserProfilePasswordSection,
- UserProfileMfaSection,
- UserProfileDeleteSection,
-} from '@clerk/ui/experimental';
-
-export default function Page() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}`;
-
-const provider = () => `'use client'
-import { ClerkProvider } from "@clerk/nextjs";
-
-export function Provider({ children }: { children: any }) {
- return (
-
- {children}
-
- )
-}`;
-
-const layout = () => `import './globals.css';
-import { Inter } from 'next/font/google';
-import { Provider } from './provider';
-
-const inter = Inter({ subsets: ['latin'] });
-
-export const metadata = {
- title: 'Create Next App',
- description: 'Generated by create next app',
-};
-
-export default function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
- {children}
-
-
- );
-}`;
-
-test.describe('composed UserProfile exports @generic', () => {
- test.describe.configure({ mode: 'serial' });
- let app: Application;
- let fakeUser: FakeUser;
-
- test.beforeAll(async () => {
- app = await appConfigs.next.appRouter
- .clone()
- // The composed exports are bundled into the app (they run in the host React tree and read the
- // loaded clerk instance via useClerk()), so the app depends on @clerk/ui directly.
- .addDependency('@clerk/ui', PKGLAB)
- .addFile('src/app/provider.tsx', provider)
- .addFile('src/app/layout.tsx', layout)
- .addFile('src/app/composed/user/page.tsx', composedUserProfilePage)
- .commit();
- await app.setup();
- await app.withEnv(appConfigs.envs.withEmailCodes);
- await app.dev();
-
- const m = createTestUtils({ app });
- fakeUser = m.services.users.createFakeUser(test, {
- withUsername: true,
- fictionalEmail: true,
- withPhoneNumber: true,
- });
- await m.services.users.createBapiUser({
- ...fakeUser,
- username: undefined,
- phoneNumber: undefined,
- });
- });
-
- test.afterAll(async () => {
- try {
- await fakeUser.deleteIfExists();
- } finally {
- await app.teardown();
- }
- });
-
- // Sign in as `user` and open the composed profile page. The composed provider renders null until
- // the user + environment are loaded and emits no `.cl-userProfile-root`, so we wait on a rendered
- // affordance (the profile section's "Update profile" action) rather than the standard root.
- const signInAndVisitComposedUserProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
- const u = createTestUtils({ app, page, context });
- await u.po.signIn.goTo();
- await u.po.signIn.waitForMounted();
- await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
- await u.po.expect.toBeSignedIn();
- await u.page.goToRelative('/composed/user');
- await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
- return u;
- };
-
- // The flows below reuse the shared `u.po.userProfile` step helpers and mirror the assertions in
- // `user-profile.test.ts` exactly; only navigation is composed-specific.
-
- test('can update the username', async ({ page, context }) => {
- const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
-
- await u.po.userProfile.clickSetUsername();
- await u.po.userProfile.waitForSectionCardOpened('username');
- await u.po.userProfile.typeUsername(fakeUser.username);
- await u.page.getByText(/Save/i).click();
- await u.po.userProfile.waitForSectionCardClosed('username');
-
- const username = await u.page.locator('.cl-profileSectionItem__username').innerText();
- expect(username).toContain(fakeUser.username);
- });
-
- test('can update first and last name', async ({ page, context }) => {
- const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
-
- await u.po.userProfile.clickToUpdateProfile();
- await u.po.userProfile.waitForSectionCardOpened('profile');
- await u.po.userProfile.typeFirstName('John');
- await u.po.userProfile.typeLastName('Doe');
- await u.page.getByText(/Save/i).click();
- await u.po.userProfile.waitForSectionCardClosed('profile');
-
- const fullName = await u.page.locator('.cl-profileSectionItem__profile').innerText();
- expect(fullName).toContain('John Doe');
- });
-
- test('can add a new email address', async ({ page, context }) => {
- const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
-
- await u.po.userProfile.clickAddEmailAddress();
- await u.po.userProfile.waitForSectionCardOpened('emailAddresses');
- const newFakeEmail = `new-${fakeUser.email}`;
- await u.po.userProfile.typeEmailAddress(newFakeEmail);
- await u.page.getByRole('button', { name: /^add$/i }).click();
-
- await u.po.userProfile.enterTestOtpCode();
-
- await expect(
- u.page.locator('.cl-profileSectionItem__emailAddresses').filter({ hasText: newFakeEmail }),
- ).toContainText(newFakeEmail);
- });
-
- test('can add a new phone number', async ({ page, context }) => {
- const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
-
- await u.po.userProfile.clickAddPhoneNumber();
- await u.po.userProfile.waitForSectionCardOpened('phoneNumbers');
- await u.po.userProfile.typePhoneNumber(fakeUser.phoneNumber);
- await u.page.getByRole('button', { name: /^add$/i }).click();
-
- await u.po.userProfile.enterTestOtpCode();
-
- const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
- await expect(u.page.locator('.cl-profileSectionItem__phoneNumbers')).toContainText(formattedPhoneNumber);
- });
-
- test('can add mfa authentication with a phone number', async ({ page, context }) => {
- const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
-
- // No Security tab to switch to — the composed security panel is already rendered on the page.
- await u.page.getByText(/add two-step verification/i).click();
- await u.page.getByText(/sms code/i).click();
-
- const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
- await u.page.getByRole('button', { name: formattedPhoneNumber }).click();
-
- await u.page.getByText(/sms code verification enabled/i).waitFor({ state: 'visible' });
- });
-
- test('can delete the account', async ({ page, context }) => {
- const m = createTestUtils({ app });
- const delFakeUser = m.services.users.createFakeUser(test, {
- withUsername: true,
- fictionalEmail: true,
- withPhoneNumber: true,
- });
- await m.services.users.createBapiUser({
- ...delFakeUser,
- username: undefined,
- phoneNumber: undefined,
- });
-
- try {
- const u = await signInAndVisitComposedUserProfile(page, context, delFakeUser);
-
- // The delete section is rendered directly in the composed security panel (no tab to switch).
- await u.page.getByRole('button', { name: /delete account/i }).click();
- await u.page.locator('input[name=deleteConfirmation]').fill('Delete account');
- await u.page.getByRole('button', { name: /delete account/i }).click();
-
- await u.po.expect.toBeSignedOut();
-
- const sessionCookieList = (await u.page.context().cookies()).filter(cookie =>
- cookie.name.startsWith('__session'),
- );
- expect(sessionCookieList).toHaveLength(0);
- } finally {
- await delFakeUser.deleteIfExists();
- }
- });
-});
-
-// The composed `OrganizationProfileProvider` renders null until there is an active organization
-// (`useOrganization()`). Each test signs in a user with exactly one membership, and the page
-// activates it via `setActive` before rendering the composed general sections. The section wrappers
-// (`OrganizationProfileProfileSection` / `...LeaveSection` / `...DeleteSection`) render the same
-// components as the standard `` general page.
-const composedOrganizationProfilePage = () => `'use client';
-import { useEffect } from 'react';
-import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
-import {
- OrganizationProfileProvider,
- OrganizationProfileGeneralPanel,
- OrganizationProfileProfileSection,
- OrganizationProfileLeaveSection,
- OrganizationProfileDeleteSection,
-} from '@clerk/ui/experimental';
-
-function EnsureActiveOrganization() {
- const { setActive } = useClerk();
- const { organization } = useOrganization();
- const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
-
- useEffect(() => {
- if (!isLoaded || organization) return;
- const first = userMemberships?.data?.[0]?.organization;
- if (first && setActive) {
- void setActive({ organization: first.id });
- }
- }, [isLoaded, organization, userMemberships, setActive]);
-
- return null;
-}
-
-export default function Page() {
- return (
- <>
-
-
-
-
-
-
-
-
- >
- );
-}`;
-
-// The composed `OrganizationProfileSecurityPanel` renders the whole standard security page
-// (`OrganizationSecurityPage`) — the SSO overview plus its configuration wizard. Unlike the general
-// tab it has no composable sub-sections, so the panel takes no children. It is the composed
-// counterpart to the security route the standard `` renders.
-const composedOrganizationSecurityPage = () => `'use client';
-import { useEffect } from 'react';
-import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
-import {
- OrganizationProfileProvider,
- OrganizationProfileSecurityPanel,
-} from '@clerk/ui/experimental';
-
-function EnsureActiveOrganization() {
- const { setActive } = useClerk();
- const { organization } = useOrganization();
- const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
-
- useEffect(() => {
- if (!isLoaded || organization) return;
- const first = userMemberships?.data?.[0]?.organization;
- if (first && setActive) {
- void setActive({ organization: first.id });
- }
- }, [isLoaded, organization, userMemberships, setActive]);
-
- return null;
-}
-
-export default function Page() {
- return (
- <>
-
-
-
-
- >
- );
-}`;
-
-test.describe('composed OrganizationProfile exports @generic', () => {
- test.describe.configure({ mode: 'serial' });
- let app: Application;
- let fakeUser: FakeUser;
- let fakeOrganization: FakeOrganization;
-
- test.beforeAll(async () => {
- app = await appConfigs.next.appRouter
- .clone()
- .addDependency('@clerk/ui', PKGLAB)
- .addFile('src/app/provider.tsx', provider)
- .addFile('src/app/layout.tsx', layout)
- .addFile('src/app/composed/organization/page.tsx', composedOrganizationProfilePage)
- .addFile('src/app/composed/organization-security/page.tsx', composedOrganizationSecurityPage)
- .commit();
- await app.setup();
- await app.withEnv(appConfigs.envs.withEmailCodes);
- await app.dev();
-
- const m = createTestUtils({ app });
- fakeUser = m.services.users.createFakeUser(test, { fictionalEmail: true });
- const user = await m.services.users.createBapiUser(fakeUser);
- fakeOrganization = await m.services.users.createFakeOrganization(user.id);
- });
-
- test.afterAll(async () => {
- try {
- // The delete test removes its own organization; ignore if this one is already gone.
- await fakeOrganization.delete().catch(() => {});
- await fakeUser.deleteIfExists();
- } finally {
- await app.teardown();
- }
- });
-
- // Sign in as `user` and open the composed organization profile page, waiting for the active org's
- // "Update profile" affordance (the composed provider renders null until an org is active).
- const signInAndVisitComposedOrganizationProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
- const u = createTestUtils({ app, page, context });
- await u.po.signIn.goTo();
- await u.po.signIn.waitForMounted();
- await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
- await u.po.expect.toBeSignedIn();
- await u.page.goToRelative('/composed/organization');
- await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
- return u;
- };
-
- test('renders the composed organization general sections', async ({ page, context }) => {
- const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
-
- // The active organization's name is surfaced in the profile section, and the danger section
- // (delete/leave) renders for the admin.
- await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(fakeOrganization.name);
- await expect(u.page.getByRole('button', { name: /delete organization/i })).toBeVisible();
- });
-
- test('can rename the organization', async ({ page, context }) => {
- const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
-
- const newName = `${fakeOrganization.name}-renamed`;
- await u.page.getByText(/update profile/i).click();
- const nameInput = u.page.getByLabel('Name', { exact: true });
- await nameInput.fill(newName);
- await u.page.getByText(/Save/i).click();
-
- await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(newName);
-
- // Assert the mutation actually reached the backend, not just the DOM.
- const updated = await u.services.clerk.organizations.getOrganization({
- organizationId: fakeOrganization.organization.id,
- });
- expect(updated.name).toBe(newName);
- });
-
- test('can delete the organization', async ({ page, context }) => {
- const m = createTestUtils({ app });
- const delFakeUser = m.services.users.createFakeUser(test, { fictionalEmail: true });
- const delUser = await m.services.users.createBapiUser(delFakeUser);
- const delOrg = await m.services.users.createFakeOrganization(delUser.id);
-
- try {
- const u = await signInAndVisitComposedOrganizationProfile(page, context, delFakeUser);
-
- await u.page.getByRole('button', { name: /delete organization/i }).click();
-
- // The confirmation card requires typing the organization name (its placeholder).
- await expect(u.page.getByText(/are you sure you want to delete this organization/i)).toBeVisible();
- await u.page.getByPlaceholder(delOrg.name).fill(delOrg.name);
- await u.page.getByRole('button', { name: /delete organization/i }).click();
-
- // Unlike the modal component, the composed provider renders null the moment the org is gone
- // (useOrganization() -> null), so there is no lingering success screen to assert on. The
- // observable parity outcome is that the deletion reached the backend.
- await expect
- .poll(
- async () => {
- try {
- await u.services.clerk.organizations.getOrganization({ organizationId: delOrg.organization.id });
- return true;
- } catch {
- return false;
- }
- },
- { timeout: 15_000 },
- )
- .toBe(false);
- } finally {
- await delFakeUser.deleteIfExists();
- }
- });
-
- test('renders the composed organization security page', async ({ page, context }) => {
- const u = createTestUtils({ app, page, context });
- await u.po.signIn.goTo();
- await u.po.signIn.waitForMounted();
- await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
- await u.po.expect.toBeSignedIn();
- await u.page.goToRelative('/composed/organization-security');
-
- // The composed security panel renders the same OrganizationSecurityPage the standard component
- // shows on its security route: the "Security" page header plus the SSO overview section. This is
- // the composed counterpart to the security tab (there are no composable sub-sections here).
- await expect(u.page.getByRole('heading', { name: /^security$/i })).toBeVisible();
- await expect(u.page.getByText(/^SSO$/)).toBeVisible();
- await expect(u.page.locator('.cl-profileSection__sso').getByRole('button', { name: /^configure$/i })).toBeVisible();
- });
-});
diff --git a/integration/tests/composed/organization-profile.test.ts b/integration/tests/composed/organization-profile.test.ts
new file mode 100644
index 00000000000..a5d9020f111
--- /dev/null
+++ b/integration/tests/composed/organization-profile.test.ts
@@ -0,0 +1,121 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test';
+
+import type { FakeOrganization, FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withPattern: ['next.appRouterBundledUI.*'] })(
+ 'composed OrganizationProfile exports @composed',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+ let fakeUser: FakeUser;
+ let fakeOrganization: FakeOrganization;
+
+ test.beforeAll(async () => {
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser(test, { fictionalEmail: true });
+ const user = await m.services.users.createBapiUser(fakeUser);
+ fakeOrganization = await m.services.users.createFakeOrganization(user.id);
+ });
+
+ test.afterAll(async () => {
+ // The delete test removes its own organization; ignore if this one is already gone.
+ await fakeOrganization?.delete().catch(() => {});
+ await fakeUser?.deleteIfExists();
+ });
+
+ // Sign in as `user` and open the composed organization profile page, waiting for the active org's
+ // "Update profile" affordance (the composed provider renders null until an org is active).
+ const signInAndVisitComposedOrganizationProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ test('renders the composed organization general sections', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ // The active organization's name is surfaced in the profile section, and the danger section
+ // (delete/leave) renders for the admin.
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(fakeOrganization.name);
+ await expect(u.page.getByRole('button', { name: /delete organization/i })).toBeVisible();
+ });
+
+ test('can rename the organization', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ const newName = `${fakeOrganization.name}-renamed`;
+ await u.page.getByText(/update profile/i).click();
+ const nameInput = u.page.getByLabel('Name', { exact: true });
+ await nameInput.fill(newName);
+ await u.page.getByText(/Save/i).click();
+
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(newName);
+
+ // Assert the mutation actually reached the backend, not just the DOM.
+ const updated = await u.services.clerk.organizations.getOrganization({
+ organizationId: fakeOrganization.organization.id,
+ });
+ expect(updated.name).toBe(newName);
+ });
+
+ test('can delete the organization', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser(test, { fictionalEmail: true });
+ const delUser = await m.services.users.createBapiUser(delFakeUser);
+ const delOrg = await m.services.users.createFakeOrganization(delUser.id);
+
+ try {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, delFakeUser);
+
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // The confirmation card requires typing the organization name (its placeholder).
+ await expect(u.page.getByText(/are you sure you want to delete this organization/i)).toBeVisible();
+ await u.page.getByPlaceholder(delOrg.name).fill(delOrg.name);
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // Unlike the modal component, the composed provider renders null the moment the org is gone
+ // (useOrganization() -> null), so there is no lingering success screen to assert on. The
+ // observable parity outcome is that the deletion reached the backend.
+ await expect
+ .poll(
+ async () => {
+ try {
+ await u.services.clerk.organizations.getOrganization({ organizationId: delOrg.organization.id });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(false);
+ } finally {
+ await delFakeUser.deleteIfExists();
+ }
+ });
+
+ test('renders the composed organization security page', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization-security');
+
+ // The composed security panel renders the same OrganizationSecurityPage the standard component
+ // shows on its security route: the "Security" page header plus the SSO overview section. This is
+ // the composed counterpart to the security tab (there are no composable sub-sections here).
+ await expect(u.page.getByRole('heading', { name: /^security$/i })).toBeVisible();
+ await expect(u.page.getByText(/^SSO$/)).toBeVisible();
+ await expect(
+ u.page.locator('.cl-profileSection__sso').getByRole('button', { name: /^configure$/i }),
+ ).toBeVisible();
+ });
+ },
+);
diff --git a/integration/tests/composed/user-profile.test.ts b/integration/tests/composed/user-profile.test.ts
new file mode 100644
index 00000000000..85d1e6db3a7
--- /dev/null
+++ b/integration/tests/composed/user-profile.test.ts
@@ -0,0 +1,169 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+import { stringPhoneNumber } from '../../testUtils/phoneUtils';
+
+/**
+ * E2E parity coverage for the experimental composed exports from `@clerk/ui/experimental`.
+ *
+ * The composed API (`UserProfileProvider`, `UserProfileAccountPanel`, `UserProfileEmailSection`,
+ * ...) lets a developer compose the profile UI from flat exports. Its section wrappers render the
+ * exact same underlying components as the standard `` (e.g. `UserProfileEmailSection`
+ * -> `AccountEmails` from `AccountSections`), so the trusted `user-profile.test.ts` flows apply
+ * unchanged. This suite reuses the shared `@clerk/testing` page-object step helpers (`u.po.userProfile.*`)
+ * and the same assertions as `user-profile.test.ts`, so if a composed export diverges from the
+ * standard behavior an existing, trusted assertion fails.
+ *
+ * `user-profile.test.ts` is intentionally left untouched while this API is experimental. The only
+ * differences handled locally here are the composed component's lack of chrome:
+ * - it does not render the `.cl-userProfile-root` wrapper, so the mount wait keys off a rendered
+ * affordance instead;
+ * - it has no Account/Security tabs — the page renders both panels, so security sections are
+ * already present (no `switchToSecurityTab()`).
+ */
+
+testAgainstRunningApps({ withPattern: ['next.appRouterBundledUI.*'] })(
+ 'composed UserProfile exports @composed',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser(test, {
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...fakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+ });
+
+ test.afterAll(async () => {
+ await fakeUser?.deleteIfExists();
+ });
+
+ // Sign in as `user` and open the composed profile page. The composed provider renders null until
+ // the user + environment are loaded and emits no `.cl-userProfile-root`, so we wait on a rendered
+ // affordance (the profile section's "Update profile" action) rather than the standard root.
+ const signInAndVisitComposedUserProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/user');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ // The flows below reuse the shared `u.po.userProfile` step helpers and mirror the assertions in
+ // `user-profile.test.ts` exactly; only navigation is composed-specific.
+
+ test('can update the username', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickSetUsername();
+ await u.po.userProfile.waitForSectionCardOpened('username');
+ await u.po.userProfile.typeUsername(fakeUser.username);
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('username');
+
+ const username = await u.page.locator('.cl-profileSectionItem__username').innerText();
+ expect(username).toContain(fakeUser.username);
+ });
+
+ test('can update first and last name', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickToUpdateProfile();
+ await u.po.userProfile.waitForSectionCardOpened('profile');
+ await u.po.userProfile.typeFirstName('John');
+ await u.po.userProfile.typeLastName('Doe');
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('profile');
+
+ const fullName = await u.page.locator('.cl-profileSectionItem__profile').innerText();
+ expect(fullName).toContain('John Doe');
+ });
+
+ test('can add a new email address', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddEmailAddress();
+ await u.po.userProfile.waitForSectionCardOpened('emailAddresses');
+ const newFakeEmail = `new-${fakeUser.email}`;
+ await u.po.userProfile.typeEmailAddress(newFakeEmail);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ await expect(
+ u.page.locator('.cl-profileSectionItem__emailAddresses').filter({ hasText: newFakeEmail }),
+ ).toContainText(newFakeEmail);
+ });
+
+ test('can add a new phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddPhoneNumber();
+ await u.po.userProfile.waitForSectionCardOpened('phoneNumbers');
+ await u.po.userProfile.typePhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await expect(u.page.locator('.cl-profileSectionItem__phoneNumbers')).toContainText(formattedPhoneNumber);
+ });
+
+ test('can add mfa authentication with a phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ // No Security tab to switch to — the composed security panel is already rendered on the page.
+ await u.page.getByText(/add two-step verification/i).click();
+ await u.page.getByText(/sms code/i).click();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: formattedPhoneNumber }).click();
+
+ await u.page.getByText(/sms code verification enabled/i).waitFor({ state: 'visible' });
+ });
+
+ test('can delete the account', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser(test, {
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...delFakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+
+ try {
+ const u = await signInAndVisitComposedUserProfile(page, context, delFakeUser);
+
+ // The delete section is rendered directly in the composed security panel (no tab to switch).
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+ await u.page.locator('input[name=deleteConfirmation]').fill('Delete account');
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+
+ await u.po.expect.toBeSignedOut();
+
+ const sessionCookieList = (await u.page.context().cookies()).filter(cookie =>
+ cookie.name.startsWith('__session'),
+ );
+ expect(sessionCookieList).toHaveLength(0);
+ } finally {
+ await delFakeUser.deleteIfExists();
+ }
+ });
+ },
+);
diff --git a/package.json b/package.json
index 950eda61070..e1e5ff9ba61 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"test:integration:cache-components": "E2E_APP_ID=next.cacheComponents pnpm test:integration:base --grep @cache-components",
"test:integration:chrome-extension": "pnpm playwright test --config integration/playwright.chrome-extension.config.ts",
"test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts",
+ "test:integration:composed": "E2E_APP_ID=next.appRouterBundledUI.* pnpm test:integration:base --grep @composed",
"test:integration:custom": "pnpm test:integration:base --grep @custom",
"test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts",
"test:integration:electron": "pnpm test:integration:base --grep @electron",
diff --git a/turbo.json b/turbo.json
index 775c85d7f26..1b769267a5e 100644
--- a/turbo.json
+++ b/turbo.json
@@ -702,6 +702,27 @@
"outputs": ["integration/playwright-report/**"],
"outputLogs": "new-only"
},
+ "//#test:integration:composed": {
+ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"],
+ "inputs": [
+ "integration/**",
+ "packages/*/package.json",
+ "packages/*/src/**",
+ "packages/*/tsconfig.json",
+ "packages/*/tsconfig.build.json",
+ "packages/*/tsconfig.declarations.json",
+ "packages/*/tsdown.config.*",
+ "packages/*/tsup.config.*",
+ "packages/*/rspack.config.*",
+ "packages/*/vite.config.*",
+ "!packages/*/src/**/*.test.*",
+ "!packages/*/src/**/__tests__/**",
+ "!packages/*/src/**/__mocks__/**",
+ "!packages/*/src/**/__snapshots__/**"
+ ],
+ "outputs": ["integration/playwright-report/**"],
+ "outputLogs": "new-only"
+ },
"//#test:integration:custom": {
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"],
"inputs": [