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": [