diff --git a/.changeset/sweet-olives-notice.md b/.changeset/sweet-olives-notice.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/sweet-olives-notice.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 7564ade279a..b747bd2ec60 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -51,7 +51,7 @@ "lint:attw": "attw --pack . --profile node16 --ignore-rules named-exports", "lint:publint": "publint || true", "postbuild:disabled": "node ../../scripts/search-for-rhc.mjs file dist/clerk.no-rhc.mjs", - "test:disabled": "vitest --watch=false", + "test": "vitest --watch=false", "test:sandbox:integration": "playwright test", "test:sandbox:integration:ui": "playwright test --ui", "test:sandbox:integration:update-snapshots": "playwright test --update-snapshots", @@ -80,6 +80,7 @@ "devDependencies": { "@clerk/msw": "workspace:^", "@clerk/testing": "workspace:^", + "@emotion/react": "11.11.1", "@rsdoctor/rspack-plugin": "^0.4.13", "@rspack/cli": "^1.6.0", "@rspack/core": "^1.6.0", diff --git a/packages/clerk-js/src/core/__tests__/signals.test.ts b/packages/clerk-js/src/core/__tests__/signals.test.ts index b7b8eef587f..fcbaf483c41 100644 --- a/packages/clerk-js/src/core/__tests__/signals.test.ts +++ b/packages/clerk-js/src/core/__tests__/signals.test.ts @@ -62,9 +62,10 @@ describe('errorsToParsedErrors', () => { const result = errorsToParsedErrors(error, initialFields); expect(result.fields).toEqual({ emailAddress: null, password: null }); - // When there are no field errors, individual ClerkAPIError instances are put in raw - expect(result.raw).toEqual([error.errors[0]]); - // Note: global is null when errors are processed individually without field errors - expect(result.global).toBeNull(); + // raw contains the full error so consumers can access any property they need + expect(result.raw).toEqual([error]); + // global contains the wrapped error for display purposes + expect(result.global).toBeTruthy(); + expect(result.global?.length).toBe(1); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index db972315dcb..a2e905a854c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1857,7 +1857,7 @@ describe('SignIn', () => { const mockSetActive = vi.fn().mockResolvedValue({}); SignIn.clerk = { - client: { reload: mockReload }, + client: { reload: mockReload, sessions: [] }, setActive: mockSetActive, } as any; @@ -1874,7 +1874,7 @@ describe('SignIn', () => { const mockNavigate = vi.fn(); SignIn.clerk = { - client: { reload: mockReload }, + client: { reload: mockReload, sessions: [] }, setActive: mockSetActive, } as any; diff --git a/packages/clerk-js/src/test/fixtures.ts b/packages/clerk-js/src/test/fixtures.ts index e84704813e6..fc325173015 100644 --- a/packages/clerk-js/src/test/fixtures.ts +++ b/packages/clerk-js/src/test/fixtures.ts @@ -12,7 +12,13 @@ import type { UserSettingsJSON, } from '@clerk/shared/types'; -import { containsAllOfType } from '../ui/utils/containsAllOf'; +/** + * Enforces that an array contains ALL keys of T + */ +const containsAllOfType = + () => + >(array: U & ([T] extends [U[number]] ? unknown : 'Invalid')) => + array; export const createBaseEnvironmentJSON = (): EnvironmentJSON => { return { diff --git a/packages/clerk-js/src/test/utils.ts b/packages/clerk-js/src/test/utils.ts index 76d0f75678b..ef0db827660 100644 --- a/packages/clerk-js/src/test/utils.ts +++ b/packages/clerk-js/src/test/utils.ts @@ -66,7 +66,6 @@ export const mockWebAuthn = (fn: () => void) => { }); }; -export * from './create-fixtures'; // Export everything from @testing-library/react except render, then export our custom render export { screen, diff --git a/packages/shared/src/internal/clerk-js/redirectUrls.ts b/packages/shared/src/internal/clerk-js/redirectUrls.ts index 1d2a8797da5..cf9cdaf9386 100644 --- a/packages/shared/src/internal/clerk-js/redirectUrls.ts +++ b/packages/shared/src/internal/clerk-js/redirectUrls.ts @@ -74,7 +74,7 @@ export class RedirectUrls { this.fromSearchParams.signInFallbackRedirectUrl || this.fromProps.signInFallbackRedirectUrl || this.fromOptions.signInFallbackRedirectUrl; - const redirectUrl = this.fromSearchParams.redirectUrl; + const redirectUrl = this.fromSearchParams.redirectUrl || this.fromProps.redirectUrl; const res: RedirectOptions & { redirectUrl?: string | null } = { signUpForceRedirectUrl, diff --git a/packages/ui/package.json b/packages/ui/package.json index 33f4ab2185b..74a043b407a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -63,9 +63,9 @@ "lint:disabled": "eslint src", "lint:publint": "publint", "showerrors": "tsc", + "test": "vitest", "test:ci": "vitest --maxWorkers=70%", "test:coverage": "vitest --collectCoverage && open coverage/lcov-report/index.html", - "test:disabled": "vitest", "type-check": "tsc --noEmit" }, "dependencies": { diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx index 2eaf39d7aa3..331bf89c46e 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx @@ -155,6 +155,7 @@ describe('InviteMembersPage', () => { { wrapper }, ); await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); + await waitFor(() => expect(getByRole('button', { name: /mydefaultrole/i })).toBeInTheDocument()); await userEvent.click(getByRole('button', { name: /mydefaultrole/i })); }); @@ -285,7 +286,7 @@ describe('InviteMembersPage', () => { }); fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]); - const { getByRole, userEvent, getByTestId } = render( + const { getByRole, userEvent, getByTestId, getByText } = render( , @@ -294,7 +295,7 @@ describe('InviteMembersPage', () => { await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); await waitFor(() => expect(getByRole('button', { name: /select role/i })).toBeInTheDocument()); await userEvent.click(getByRole('button', { name: /select role/i })); - await userEvent.click(getByRole('button', { name: /admin/i })); + await userEvent.click(getByText(/^admin$/i)); await waitFor(() => expect(getByRole('button', { name: 'Send invitations' })).not.toBeDisabled()); }); @@ -359,7 +360,9 @@ describe('InviteMembersPage', () => { expect(getByRole('button', { name: 'Send invitations' })).toBeDisabled(); await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); - expect(getByRole('button', { name: 'Send invitations' })).not.toBeDisabled(); + // Wait for the default role to be applied and the button to become enabled + await waitFor(() => expect(getByRole('button', { name: 'Send invitations' })).not.toBeDisabled()); + await waitFor(() => expect(getByRole('button', { name: /mydefaultrole/i })).toBeInTheDocument()); await userEvent.click(getByRole('button', { name: /mydefaultrole/i })); }); }); diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx index 7a65ad0da98..9e629ca99ea 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx @@ -697,13 +697,24 @@ describe('OrganizationMembers', () => { ], }); - const { container, getByText } = render(, { wrapper }); + const { container, findByText, queryAllByRole } = render(, { wrapper }); await waitForLoadingCompleted(container); - expect(getByText('Roles are temporarily locked')).toBeInTheDocument(); + // Wait for roles to be fetched (buttons becoming disabled indicates roles have loaded) + await waitFor(() => { + const adminButtons = queryAllByRole('button', { name: 'Admin' }); + expect(adminButtons.length).toBeGreaterThan(0); + adminButtons.forEach(button => expect(button).toBeDisabled()); + }); + + // Now check for the alert + expect(await findByText('Roles are temporarily locked')).toBeInTheDocument(); + // Use regex to match both curly and straight apostrophes expect( - getByText("We are updating the available roles. Once that's done, you'll be able to update roles again."), + await findByText( + /We are updating the available roles\. Once that.s done, you.ll be able to update roles again\./, + ), ).toBeInTheDocument(); }); diff --git a/packages/ui/src/components/PricingTable/utils/pricing-footer-state.ts b/packages/ui/src/components/PricingTable/utils/pricing-footer-state.ts index 634d24a9189..b8a087b523f 100644 --- a/packages/ui/src/components/PricingTable/utils/pricing-footer-state.ts +++ b/packages/ui/src/components/PricingTable/utils/pricing-footer-state.ts @@ -38,7 +38,7 @@ const valueResolution = (params: UsePricingFooterStateParams): [boolean, boolean // Active subscription if (subscription.status === 'active') { const isCanceled = !!subscription.canceledAt; - const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && Boolean(plan.annualMonthlyFee); + const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && Boolean(plan.annualMonthlyFee?.amount); const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial; if (isCanceled || isSwitchingPaidPeriod) { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index f2a8106f967..085b45919d9 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -35,12 +35,12 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = const { organizationSettings } = useEnvironment(); const [file, setFile] = useState(); - const nameField = useFormControl('name', props.organizationCreationDefaults?.form.name ?? '', { + const nameField = useFormControl('name', props.organizationCreationDefaults?.form?.name ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__name'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__name'), }); - const slugField = useFormControl('slug', props.organizationCreationDefaults?.form.slug ?? '', { + const slugField = useFormControl('slug', props.organizationCreationDefaults?.form?.slug ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__slug'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__slug'), @@ -99,7 +99,7 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = }; const isSubmitButtonDisabled = !nameField.value || !isLoaded; - const defaultLogoUrl = file === undefined ? props.organizationCreationDefaults?.form.logo : undefined; + const defaultLogoUrl = file === undefined ? props.organizationCreationDefaults?.form?.logo : undefined; return ( <> diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index 27a383d2c61..8c41f6717e3 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -1,6 +1,7 @@ import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearFetchCache } from '@/ui/hooks/useFetch'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render } from '@/test/utils'; import { @@ -9,7 +10,6 @@ import { } from '@/ui/components/OrganizationSwitcher/__tests__/test-utils'; import { TaskChooseOrganization } from '..'; -import { clearFetchCache } from '../../../../../hooks'; const { createFixtures } = bindCreateFixtures('TaskChooseOrganization'); @@ -222,6 +222,8 @@ describe('TaskChooseOrganization', () => { email_addresses: ['test@clerk.com'], create_organization_enabled: true, tasks: [{ key: 'choose-organization' }], + // Include an organization membership so user has reached max memberships + organization_memberships: [{ name: 'Existing Org', slug: 'org1' }], }); }); @@ -298,11 +300,12 @@ describe('TaskChooseOrganization', () => { }); }); - const { queryByText } = render(, { wrapper }); + const { queryByText, findByText } = render(, { wrapper }); + // Wait for loading to complete and the disabled screen to render + expect(await findByText(/you must belong to an organization/i)).toBeInTheDocument(); + expect(await findByText(/contact your organization admin for an invitation/i)).toBeInTheDocument(); expect(queryByText(/create new organization/i)).not.toBeInTheDocument(); - expect(queryByText(/you must belong to an organization/i)).toBeInTheDocument(); - expect(queryByText(/contact your organization admin for an invitation/i)).toBeInTheDocument(); }); it('with existing memberships or suggestions, displays create organization screen', async () => { diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index c32e64d23e2..30acd72fbb6 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -378,7 +378,13 @@ function SignUpStartInternal(): JSX.Element { } const canToggleEmailPhone = emailOrPhone(attributes, isProgressiveSignUp); - const visibleFields = Object.entries(fields).filter(([_, opts]) => showOptionalFields || opts?.required); + const visibleFields = Object.entries(fields).filter(([key, opts]) => { + // In case both email & phone are optional (emailOrPhone case), always show the active identifier + if ((key === 'emailAddress' || key === 'phoneNumber') && canToggleEmailPhone) { + return !!opts; + } + return showOptionalFields || opts?.required; + }); const shouldShowForm = showFormFields(userSettings) && visibleFields.length > 0; const showOauthProviders = diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index c40cf3007c9..a33326ba813 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -89,12 +89,24 @@ describe('SignUpStart', () => { }); it('should keep email optional when phone is primary with password', async () => { - const { wrapper } = await createFixtures(f => { + const { wrapper: Wrapper } = await createFixtures(f => { f.withEmailAddress({ required: false }); f.withPhoneNumber({ required: true }); f.withPassword({ required: true }); }); - render(, { wrapper }); + + const wrapperWithOptionalFields = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + render(, { wrapper: wrapperWithOptionalFields }); const emailAddress = screen.getByLabelText('Email address', { selector: 'input' }); expect(emailAddress.ariaRequired).toBe('false'); @@ -168,12 +180,24 @@ describe('SignUpStart', () => { }); it('enables optional phone number', async () => { - const { wrapper } = await createFixtures(f => { + const { wrapper: Wrapper } = await createFixtures(f => { f.withEmailAddress({ required: true }); f.withPhoneNumber({ required: false }); f.withPassword({ required: true }); }); - render(, { wrapper }); + + const wrapperWithOptionalFields = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + render(, { wrapper: wrapperWithOptionalFields }); expect(screen.getByText('Phone number').nextElementSibling?.textContent).toBe('Optional'); }); @@ -287,7 +311,7 @@ describe('SignUpStart', () => { describe('initialValues', () => { it('prefills the emailAddress field with the correct initial value', async () => { const { wrapper, props } = await createFixtures(f => { - f.withEmailAddress(); + f.withEmailAddress({ required: true }); }); props.setProps({ initialValues: { emailAddress: 'foo@clerk.com' } }); @@ -297,7 +321,7 @@ describe('SignUpStart', () => { it('prefills the phoneNumber field with the correct initial value', async () => { const { wrapper, props } = await createFixtures(f => { - f.withPhoneNumber(); + f.withPhoneNumber({ required: true }); }); props.setProps({ initialValues: { phoneNumber: '+306911111111' } }); @@ -307,7 +331,7 @@ describe('SignUpStart', () => { it('prefills the username field with the correct initial value', async () => { const { wrapper, props } = await createFixtures(f => { - f.withUsername(); + f.withUsername({ required: true }); }); props.setProps({ initialValues: { username: 'foo' } }); diff --git a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts index 0bd3c3ec31b..aeb1605ad5b 100644 --- a/packages/ui/src/components/UserProfile/__tests__/utils.test.ts +++ b/packages/ui/src/components/UserProfile/__tests__/utils.test.ts @@ -1,7 +1,7 @@ import type { VerificationJSON } from '@clerk/shared/types'; import { describe, expect, it } from 'vitest'; -import { EmailAddress, PhoneNumber } from '../../../../core/resources'; +import { EmailAddress, PhoneNumber } from '@/core/resources'; import { sortIdentificationBasedOnVerification } from '../utils'; describe('UserProfile utils', () => { diff --git a/packages/ui/src/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/ui/src/hooks/__tests__/useCoreOrganizationList.test.tsx index d3f6d06ff5e..dbe8147240e 100644 --- a/packages/ui/src/hooks/__tests__/useCoreOrganizationList.test.tsx +++ b/packages/ui/src/hooks/__tests__/useCoreOrganizationList.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { act, renderHook, waitFor } from '../../../test/utils'; +import { act, renderHook, waitFor } from '@/test/utils'; import { createFakeUserOrganizationInvitation, createFakeUserOrganizationMembership, diff --git a/packages/ui/src/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx b/packages/ui/src/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx index 33def92779e..b9a013d16d9 100644 --- a/packages/ui/src/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx +++ b/packages/ui/src/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx @@ -2,7 +2,7 @@ import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { bindCreateFixtures } from '../../../test/utils'; +import { bindCreateFixtures } from '@/test/utils'; import { useEnabledThirdPartyProviders } from '../useEnabledThirdPartyProviders'; const { createFixtures } = bindCreateFixtures('SignUp'); diff --git a/packages/ui/src/test/core-fixtures.ts b/packages/ui/src/test/core-fixtures.ts new file mode 100644 index 00000000000..4520dccdf3c --- /dev/null +++ b/packages/ui/src/test/core-fixtures.ts @@ -0,0 +1,287 @@ +import type { + Clerk, + EmailAddressJSON, + ExternalAccountJSON, + OAuthProvider, + OrganizationCustomRoleKey, + OrganizationJSON, + OrganizationMembershipJSON, + OrganizationPermissionKey, + PhoneNumberJSON, + SessionJSON, + SignInJSON, + SignUpJSON, + UserJSON, +} from '@clerk/shared/types'; +import { vi } from 'vitest'; + +export const mockJwt = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; + +export type OrgParams = Partial & { + role?: OrganizationCustomRoleKey; + permissions?: OrganizationPermissionKey[]; +}; + +type WithUserParams = Omit< + Partial, + 'email_addresses' | 'phone_numbers' | 'external_accounts' | 'organization_memberships' +> & { + email_addresses?: Array>; + phone_numbers?: Array>; + external_accounts?: Array>; + organization_memberships?: Array; +}; + +type WithSessionParams = Partial; + +export const getOrganizationId = (orgParams: OrgParams) => orgParams?.id || orgParams?.name || 'test_id'; + +export const createOrganizationMembership = (params: OrgParams): OrganizationMembershipJSON => { + const { role, permissions, ...orgParams } = params; + return { + created_at: new Date().getTime(), + id: getOrganizationId(orgParams), + object: 'organization_membership', + organization: { + created_at: new Date().getTime(), + id: getOrganizationId(orgParams), + image_url: + 'https://img.clerk.com/eyJ0eXBlIjoiZGVmYXVsdCIsImlpZCI6Imluc18xbHlXRFppb2JyNjAwQUtVZVFEb1NsckVtb00iLCJyaWQiOiJ1c2VyXzJKbElJQTN2VXNjWXh1N2VUMnhINmFrTGgxOCIsImluaXRpYWxzIjoiREsifQ?width=160', + max_allowed_memberships: 3, + members_count: 1, + name: 'Org', + object: 'organization', + pending_invitations_count: 0, + public_metadata: {}, + slug: null, + updated_at: new Date().getTime(), + ...orgParams, + } as OrganizationJSON, + public_metadata: {}, + role: role || 'admin', + permissions: permissions || [ + 'org:sys_domains:manage', + 'org:sys_domains:read', + 'org:sys_memberships:manage', + 'org:sys_memberships:read', + 'org:sys_profile:delete', + 'org:sys_profile:manage', + ], + updated_at: new Date().getTime(), + } as OrganizationMembershipJSON; +}; + +export const createEmail = (params?: Partial): EmailAddressJSON => { + return { + object: 'email_address', + id: params?.email_address || '', + email_address: 'test@clerk.com', + reserved: false, + verification: { + status: 'verified', + strategy: 'email_link', + attempts: null, + expire_at: 1635977979774, + }, + linked_to: [], + ...params, + } as EmailAddressJSON; +}; + +export const createPhoneNumber = (params?: Partial): PhoneNumberJSON => { + return { + object: 'phone_number', + id: params?.phone_number || '', + phone_number: '+30 691 1111111', + reserved: false, + verification: { + status: 'verified', + strategy: 'phone_code', + attempts: null, + expire_at: 1635977979774, + }, + linked_to: [], + ...params, + } as PhoneNumberJSON; +}; + +export const createExternalAccount = (params?: Partial): ExternalAccountJSON => { + return { + id: params?.provider || '', + object: 'external_account', + provider: 'google', + identification_id: '98675202', + provider_user_id: '3232', + approved_scopes: '', + email_address: 'test@clerk.com', + first_name: 'First name', + last_name: 'Last name', + image_url: '', + username: '', + phoneNumber: '', + verification: { + status: 'verified', + strategy: '', + attempts: null, + expire_at: 1635977979774, + }, + ...params, + } as ExternalAccountJSON; +}; + +export const createUser = (params?: WithUserParams): UserJSON => { + const res = { + object: 'user', + id: params?.id || 'user_123', + primary_email_address_id: '', + primary_phone_number_id: '', + primary_web3_wallet_id: '', + image_url: '', + username: 'testUsername', + web3_wallets: [], + password: '', + profile_image_id: '', + first_name: 'FirstName', + last_name: 'LastName', + password_enabled: false, + totp_enabled: false, + backup_code_enabled: false, + two_factor_enabled: false, + public_metadata: {}, + unsafe_metadata: {}, + last_sign_in_at: null, + updated_at: new Date().getTime(), + created_at: new Date().getTime(), + ...params, + email_addresses: (params?.email_addresses || []).map(e => + typeof e === 'string' ? createEmail({ email_address: e }) : createEmail(e), + ), + phone_numbers: (params?.phone_numbers || []).map(n => + typeof n === 'string' ? createPhoneNumber({ phone_number: n }) : createPhoneNumber(n), + ), + external_accounts: (params?.external_accounts || []).map(p => + typeof p === 'string' ? createExternalAccount({ provider: p }) : createExternalAccount(p), + ), + organization_memberships: (params?.organization_memberships || []).map(o => + typeof o === 'string' ? createOrganizationMembership({ name: o }) : createOrganizationMembership(o), + ), + } as UserJSON; + res.primary_email_address_id = res.email_addresses[0]?.id; + return res; +}; + +export const createSession = (sessionParams: WithSessionParams = {}, user: Partial = {}) => { + return { + object: 'session', + id: sessionParams.id, + status: sessionParams.status, + expire_at: sessionParams.expire_at || Date.now() + 5000, + abandon_at: sessionParams.abandon_at, + last_active_at: sessionParams.last_active_at || Date.now(), + last_active_organization_id: sessionParams.last_active_organization_id, + actor: sessionParams.actor, + user: createUser({}), + public_user_data: { + first_name: user.first_name, + last_name: user.last_name, + image_url: user.image_url, + has_image: user.has_image, + identifier: user.email_addresses?.find(e => e.id === user.primary_email_address_id)?.email_address || '', + }, + created_at: sessionParams.created_at || Date.now() - 1000, + updated_at: sessionParams.updated_at || Date.now(), + last_active_token: { + object: 'token', + jwt: mockJwt, + }, + } as SessionJSON; +}; + +export const createSignIn = (signInParams: Partial = {}, user: Partial = {}) => { + return { + id: signInParams.id, + created_session_id: signInParams.created_session_id, + status: signInParams.status, + first_factor_verification: signInParams.first_factor_verification, + identifier: signInParams.identifier, + object: 'sign_in', + second_factor_verification: signInParams.second_factor_verification, + supported_first_factors: signInParams.supported_first_factors, + supported_second_factors: signInParams.supported_second_factors, + user_data: { + first_name: user.first_name, + last_name: user.last_name, + image_url: user.image_url, + has_image: user.has_image, + }, + } as SignInJSON; +}; + +export const createSignUp = (signUpParams: Partial = {}) => { + return { + id: signUpParams.id, + created_session_id: signUpParams.created_session_id, + status: signUpParams.status, + abandon_at: signUpParams.abandon_at, + created_user_id: signUpParams.created_user_id, + email_address: signUpParams.email_address, + external_account: signUpParams.external_account, + external_account_strategy: signUpParams.external_account_strategy, + first_name: signUpParams.first_name, + has_password: signUpParams.has_password, + last_name: signUpParams.last_name, + legal_accepted_at: signUpParams.legal_accepted_at, + locale: signUpParams.locale, + missing_fields: signUpParams.missing_fields, + object: 'sign_up', + optional_fields: signUpParams.optional_fields, + phone_number: signUpParams.phone_number, + required_fields: signUpParams.required_fields, + unsafe_metadata: signUpParams.unsafe_metadata, + unverified_fields: signUpParams.unverified_fields, + username: signUpParams.username, + verifications: signUpParams.verifications, + web3_wallet: signUpParams.web3_wallet, + } as SignUpJSON; +}; + +export const clerkMock = (params?: Partial) => { + return { + getFapiClient: vi.fn().mockReturnValue({ + request: vi.fn().mockReturnValue({ payload: { object: 'token', jwt: mockJwt }, status: 200 }), + }), + ...params, + }; +}; + +type RecursivePartial = { + [P in keyof T]?: RecursivePartial; +}; + +export const mockFetch = (ok = true, status = 200, responsePayload = {}) => { + // @ts-ignore + global.fetch = vi.fn(() => { + return Promise.resolve>({ + status, + statusText: status.toString(), + ok, + json: () => Promise.resolve(responsePayload), + }); + }); +}; + +export const mockNetworkFailedFetch = () => { + // @ts-ignore + global.fetch = vi.fn(() => { + return Promise.reject(new TypeError('Failed to fetch')); + }); +}; + +export const mockDevClerkInstance = { + frontendApi: 'clerk.example.com', + instanceType: 'development', + isSatellite: false, + version: 'test-0.0.0', + domain: '', +}; diff --git a/packages/ui/src/test/create-fixtures.tsx b/packages/ui/src/test/create-fixtures.tsx new file mode 100644 index 00000000000..09e461a5822 --- /dev/null +++ b/packages/ui/src/test/create-fixtures.tsx @@ -0,0 +1,149 @@ +/* eslint-disable */ +// @ts-nocheck + +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import type { ClerkOptions, ClientJSON, EnvironmentJSON, LoadedClerk } from '@clerk/shared/types'; +import { useState } from 'react'; +import { vi } from 'vitest'; + +import { Clerk as ClerkCtor } from '@/core/clerk'; +import { Client, Environment } from '@/core/resources'; +import { + ComponentContextProvider, + CoreClerkContextWrapper, + EnvironmentProvider, + ModuleManagerProvider, + OptionsProvider, +} from '@/ui/contexts'; +import { AppearanceProvider } from '@/ui/customizables'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import { RouteContext } from '@/ui/router'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import type { AvailableComponentName, AvailableComponentProps } from '@/ui/types'; + +import { createClientFixtureHelpers, createEnvironmentFixtureHelpers } from './fixture-helpers'; +import { createBaseClientJSON, createBaseEnvironmentJSON } from './fixtures'; +import { mockClerkMethods, mockRouteContextValue } from './mock-helpers'; + +const createInitialStateConfigParam = (baseEnvironment: EnvironmentJSON, baseClient: ClientJSON) => { + return { + ...createEnvironmentFixtureHelpers(baseEnvironment), + ...createClientFixtureHelpers(baseClient), + }; +}; + +type FParam = ReturnType; +type ConfigFn = (f: FParam) => void; + +export const bindCreateFixtures = ( + componentName: Parameters[0], + mockOpts?: { + router?: Parameters[0]; + }, +) => { + return { createFixtures: unboundCreateFixtures(componentName, mockOpts) }; +}; + +const unboundCreateFixtures = ( + componentName: AvailableComponentName, + mockOpts?: { + router?: Parameters[0]; + }, +) => { + const createFixtures = async (...configFns: ConfigFn[]) => { + const baseEnvironment = createBaseEnvironmentJSON(); + const baseClient = createBaseClientJSON(); + configFns = configFns.filter(Boolean); + + if (configFns.length) { + const fParam = createInitialStateConfigParam(baseEnvironment, baseClient); + configFns.forEach(configFn => configFn(fParam)); + } + + const environmentMock = new Environment(baseEnvironment); + Environment.getInstance().fetch = vi.fn(() => Promise.resolve(environmentMock)); + + // @ts-expect-error We cannot mess with the singleton when tests are running in parallel + const clientMock = new Client(baseClient); + Client.getOrCreateInstance().fetch = vi.fn(() => Promise.resolve(clientMock)); + + // Use a FAPI value for local production instances to avoid triggering the devInit flow during testing + const productionPublishableKey = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; + const tempClerk = new ClerkCtor(productionPublishableKey); + await tempClerk.load(); + const clerkMock = mockClerkMethods(tempClerk as LoadedClerk); + const optionsMock = {} as ClerkOptions; + const routerMock = mockRouteContextValue(mockOpts?.router || {}); + + const fixtures = { + clerk: clerkMock, + session: clerkMock.session, + signIn: clerkMock.client.signIn, + signUp: clerkMock.client.signUp, + environment: environmentMock, + router: routerMock, + options: optionsMock, + }; + + let componentContextProps: AvailableComponentProps; + const props = { + setProps: (props: typeof componentContextProps) => { + componentContextProps = props; + }, + }; + + // Create a mock ModuleManager for testing + const mockModuleManager: ModuleManager = { + import: vi.fn(() => Promise.resolve(undefined)), + }; + + const MockClerkProvider = (props: any) => { + const { children } = props; + const [swrConfig] = useState(() => ({ provider: () => new Map() })); + + const componentsWithoutContext = [ + 'UsernameSection', + 'UserProfileSection', + 'SubscriptionDetails', + 'PlanDetails', + 'Checkout', + ]; + const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? ( + + {children} + + ) : ( + <>{children} + ); + + return ( + + + + + + + + {contextWrappedChildren} + + + + + + + + ); + }; + + return { wrapper: MockClerkProvider, fixtures, props }; + }; + createFixtures.config = (fn: ConfigFn) => fn; + return createFixtures; +}; diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts new file mode 100644 index 00000000000..012403b792e --- /dev/null +++ b/packages/ui/src/test/fixture-helpers.ts @@ -0,0 +1,605 @@ +import type { + ClientJSON, + DisplayConfigJSON, + EmailAddressJSON, + EnvironmentJSON, + ExternalAccountJSON, + OAuthProvider, + OrganizationEnrollmentMode, + PhoneNumberJSON, + PublicUserDataJSON, + SessionJSON, + SignInJSON, + SignUpJSON, + UserJSON, + UserSettingsJSON, + VerificationJSON, +} from '@clerk/shared/types'; + +import type { OrgParams } from '@/test/core-fixtures'; +import { createUser, getOrganizationId } from '@/test/core-fixtures'; + +import { SIGN_UP_MODES } from '@/core/constants'; +import { createUserFixture } from './fixtures'; + +export const createEnvironmentFixtureHelpers = (baseEnvironment: EnvironmentJSON) => { + return { + ...createAuthConfigFixtureHelpers(baseEnvironment), + ...createDisplayConfigFixtureHelpers(baseEnvironment), + ...createOrganizationSettingsFixtureHelpers(baseEnvironment), + ...createBillingSettingsFixtureHelpers(baseEnvironment), + ...createUserSettingsFixtureHelpers(baseEnvironment), + }; +}; + +export const createClientFixtureHelpers = (baseClient: ClientJSON) => { + return { + ...createSignInFixtureHelpers(baseClient), + ...createSignUpFixtureHelpers(baseClient), + ...createUserFixtureHelpers(baseClient), + }; +}; + +const createUserFixtureHelpers = (baseClient: ClientJSON) => { + type WithUserParams = Omit< + Partial, + 'email_addresses' | 'phone_numbers' | 'external_accounts' | 'organization_memberships' + > & { + identifier?: string; + email_addresses?: Array>; + phone_numbers?: Array>; + external_accounts?: Array>; + organization_memberships?: Array; + tasks?: SessionJSON['tasks']; + }; + + const createPublicUserData = (params: WithUserParams) => { + return { + first_name: 'FirstName', + last_name: 'LastName', + image_url: '', + identifier: params.identifier || 'email@test.com', + user_id: '', + ...params, + } as PublicUserDataJSON; + }; + + const withUser = (params: WithUserParams) => { + baseClient.sessions = baseClient.sessions || []; + + // set the first organization as active + let activeOrganization: string | null = null; + if (params?.organization_memberships?.length) { + activeOrganization = + typeof params.organization_memberships[0] === 'string' + ? params.organization_memberships[0] + : getOrganizationId(params.organization_memberships[0]); + } + + const session = { + status: params.tasks?.length ? 'pending' : 'active', + id: baseClient.sessions.length.toString(), + object: 'session', + last_active_organization_id: activeOrganization, + actor: null, + user: createUser(params), + public_user_data: createPublicUserData(params), + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + last_active_token: { + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg', + }, + tasks: params.tasks ?? null, + current_task: params.tasks?.[0] ?? null, + } as SessionJSON; + baseClient.sessions.push(session); + }; + + return { withUser }; +}; + +const createSignInFixtureHelpers = (baseClient: ClientJSON) => { + type SignInWithEmailAddressParams = { + identifier?: string; + supportPassword?: boolean; + supportEmailCode?: boolean; + supportEmailLink?: boolean; + supportResetPassword?: boolean; + supportPasskey?: boolean; + }; + + type SignInWithPhoneNumberParams = { + identifier?: string; + supportPassword?: boolean; + supportPhoneCode?: boolean; + supportResetPassword?: boolean; + }; + + type SignInFactorTwoParams = { + identifier?: string; + supportPhoneCode?: boolean; + supportTotp?: boolean; + supportBackupCode?: boolean; + supportResetPasswordEmail?: boolean; + supportResetPasswordPhone?: boolean; + }; + + const startSignInWithEmailAddress = (params?: SignInWithEmailAddressParams) => { + const { + identifier = 'hello@clerk.com', + supportPassword = true, + supportEmailCode, + supportEmailLink, + supportResetPassword, + supportPasskey, + } = params || {}; + baseClient.sign_in = { + status: 'needs_first_factor', + identifier, + supported_identifiers: ['email_address'], + supported_first_factors: [ + ...(supportPasskey ? [{ strategy: 'passkey' }] : []), + ...(supportPassword ? [{ strategy: 'password' }] : []), + ...(supportEmailCode ? [{ strategy: 'email_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportEmailLink ? [{ strategy: 'email_link', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportResetPassword + ? [ + { + strategy: 'reset_password_email_code', + safe_identifier: identifier || 'n*****@clerk.com', + emailAddressId: 'someEmailId', + }, + ] + : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + const startSignInWithPhoneNumber = (params?: SignInWithPhoneNumberParams) => { + const { + identifier = '+301234567890', + supportPassword = true, + supportPhoneCode, + supportResetPassword, + } = params || {}; + baseClient.sign_in = { + status: 'needs_first_factor', + identifier, + supported_identifiers: ['phone_number'], + supported_first_factors: [ + ...(supportPassword ? [{ strategy: 'password' }] : []), + ...(supportPhoneCode ? [{ strategy: 'phone_code', safe_identifier: '+30********90' }] : []), + ...(supportResetPassword + ? [ + { + strategy: 'reset_password_phone_code', + safe_identifier: identifier || '+30********90', + phoneNumberId: 'someNumberId', + }, + ] + : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + const startSignInFactorTwo = (params?: SignInFactorTwoParams) => { + const { + identifier = '+30 691 1111111', + supportPhoneCode = true, + supportTotp, + supportBackupCode, + supportResetPasswordEmail, + supportResetPasswordPhone, + } = params || {}; + baseClient.sign_in = { + status: 'needs_second_factor', + identifier, + ...(supportResetPasswordEmail + ? { + first_factor_verification: { + status: 'verified', + strategy: 'reset_password_email_code', + }, + } + : {}), + ...(supportResetPasswordPhone + ? { + first_factor_verification: { + status: 'verified', + strategy: 'reset_password_phone_code', + }, + } + : {}), + supported_identifiers: ['email_address', 'phone_number'], + supported_second_factors: [ + ...(supportPhoneCode ? [{ strategy: 'phone_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportTotp ? [{ strategy: 'totp', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportBackupCode ? [{ strategy: 'backup_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo }; +}; + +const createSignUpFixtureHelpers = (baseClient: ClientJSON) => { + type SignUpWithEmailAddressParams = { + emailAddress?: string; + supportEmailCode?: boolean; + supportEmailLink?: boolean; + emailVerificationStatus?: VerificationJSON['status']; + }; + + type SignUpWithPhoneNumberParams = { + phoneNumber?: string; + }; + + const startSignUpWithEmailAddress = (params?: SignUpWithEmailAddressParams) => { + const { + emailAddress = 'hello@clerk.com', + supportEmailLink = true, + supportEmailCode = true, + emailVerificationStatus = 'unverified', + } = params || {}; + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + email_address: emailAddress, + verifications: (supportEmailLink || supportEmailCode) && { + email_address: { + strategy: (supportEmailLink && 'email_link') || (supportEmailCode && 'email_code'), + status: emailVerificationStatus, + }, + }, + missing_fields: [], + unverified_fields: emailVerificationStatus === 'unverified' ? ['email_address'] : [], + } as SignUpJSON; + }; + + const startSignUpWithPhoneNumber = (params?: SignUpWithPhoneNumberParams) => { + const { phoneNumber = '+301234567890' } = params || {}; + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + phone_number: phoneNumber, + } as SignUpJSON; + }; + + const startSignUpWithMissingLegalAccepted = () => { + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + legal_accepted_at: null, + missing_fields: ['legal_accepted'], + } as SignUpJSON; + }; + + const startSignUpWithMissingLegalAcceptedAndUnverifiedFields = (emailAddress = 'hello@clerk.com') => { + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + legal_accepted_at: null, + missing_fields: ['legal_accepted'], + email_address: emailAddress, + unverified_fields: ['email_address'], + } as SignUpJSON; + }; + + return { + startSignUpWithEmailAddress, + startSignUpWithPhoneNumber, + startSignUpWithMissingLegalAccepted, + startSignUpWithMissingLegalAcceptedAndUnverifiedFields, + }; +}; + +const createAuthConfigFixtureHelpers = (environment: EnvironmentJSON) => { + const ac = environment.auth_config; + const withMultiSessionMode = () => { + // TODO: + ac.single_session_mode = false; + }; + const withReverification = () => { + ac.reverification = true; + }; + return { withMultiSessionMode, withReverification }; +}; + +const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => { + const dc = environment.display_config; + const withSupportEmail = (opts?: { email: string }) => { + dc.support_email = opts?.email || 'support@clerk.com'; + }; + const withoutClerkBranding = () => { + dc.branded = false; + }; + const withPreferredSignInStrategy = (opts: { strategy: DisplayConfigJSON['preferred_sign_in_strategy'] }) => { + dc.preferred_sign_in_strategy = opts.strategy; + }; + + const withTermsPrivacyPolicyUrls = (opts: { + termsOfService?: DisplayConfigJSON['terms_url']; + privacyPolicy?: DisplayConfigJSON['privacy_policy_url']; + }) => { + dc.terms_url = opts.termsOfService || ''; + dc.privacy_policy_url = opts.privacyPolicy || ''; + }; + return { withSupportEmail, withoutClerkBranding, withPreferredSignInStrategy, withTermsPrivacyPolicyUrls }; +}; + +const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const os = environment.organization_settings; + const withOrganizations = () => { + os.enabled = true; + }; + const withMaxAllowedMemberships = ({ max = 5 }) => { + os.max_allowed_memberships = max; + }; + const withForceOrganizationSelection = () => { + os.force_organization_selection = true; + }; + const withOrganizationSlug = (enabled = false) => { + os.slug.disabled = !enabled; + }; + const withOrganizationCreationDefaults = (enabled = false) => { + os.organization_creation_defaults.enabled = enabled; + }; + + const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[], defaultRole?: string) => { + os.domains.enabled = true; + os.domains.enrollment_modes = modes || ['automatic_invitation', 'manual_invitation']; + os.domains.default_role = defaultRole ?? null; + }; + return { + withOrganizations, + withMaxAllowedMemberships, + withOrganizationDomains, + withForceOrganizationSelection, + withOrganizationSlug, + withOrganizationCreationDefaults, + }; +}; + +const createBillingSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const os = environment.commerce_settings.billing; + const withBilling = ({ + userEnabled = true, + userHasPaidPlans = true, + organizationEnabled = true, + organizationHasPaidPlans = true, + }: { + userEnabled?: boolean; + userHasPaidPlans?: boolean; + organizationEnabled?: boolean; + organizationHasPaidPlans?: boolean; + } = {}) => { + os.user.enabled = userEnabled; + os.user.has_paid_plans = userHasPaidPlans; + os.organization.enabled = organizationEnabled; + os.organization.has_paid_plans = organizationHasPaidPlans; + }; + + return { withBilling }; +}; + +const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const us = environment.user_settings; + us.password_settings = { + allowed_special_characters: '', + disable_hibp: false, + min_length: 8, + max_length: 999, + require_special_char: false, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + }; + us.sign_up = { + ...us.sign_up, + mode: SIGN_UP_MODES.PUBLIC, + }; + + us.username_settings = { + min_length: 4, + max_length: 40, + }; + + const emptyAttribute = { + first_factors: [], + second_factors: [], + verifications: [], + used_for_first_factor: false, + used_for_second_factor: false, + verify_at_sign_up: false, + }; + + const withPasswordComplexity = (opts?: Partial) => { + us.password_settings = { + ...us.password_settings, + ...opts, + }; + }; + + const withEmailAddress = (opts?: Partial) => { + us.attributes.email_address = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['email_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['email_code'], + verify_at_sign_up: true, + ...opts, + }; + }; + + const withEmailLink = () => { + withEmailAddress({ first_factors: ['email_link'], verifications: ['email_link'] }); + }; + + const withPhoneNumber = (opts?: Partial) => { + us.attributes.phone_number = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['phone_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['phone_code'], + verify_at_sign_up: true, + ...opts, + }; + }; + + const withPasskey = (opts?: Partial) => { + us.attributes.passkey = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['passkey'], + used_for_second_factor: false, + second_factors: [], + verifications: ['passkey'], + verify_at_sign_up: false, + ...opts, + }; + }; + + const withPasskeySettings = (opts?: Partial) => { + us.passkey_settings = { + ...us.passkey_settings, + ...opts, + }; + }; + + const withUsername = (opts?: Partial) => { + us.attributes.username = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + ...opts, + }; + }; + + const withWeb3Wallet = (opts?: Partial) => { + us.attributes.web3_wallet = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['web3_metamask_signature'], + verifications: ['web3_metamask_signature'], + ...opts, + }; + }; + + const withName = (opts?: Partial) => { + const attr = { + ...emptyAttribute, + enabled: true, + required: false, + ...opts, + }; + us.attributes.first_name = attr; + us.attributes.last_name = attr; + }; + + const withPassword = (opts?: Partial) => { + us.attributes.password = { + ...emptyAttribute, + enabled: true, + required: false, + ...opts, + }; + }; + + const withSocialProvider = (opts: { provider: OAuthProvider; authenticatable?: boolean }) => { + const { authenticatable = true, provider } = opts || {}; + const strategy = 'oauth_' + provider; + // @ts-expect-error + us.social[strategy] = { + enabled: true, + authenticatable, + strategy: strategy, + }; + }; + + const withEnterpriseSso = () => { + us.saml = { enabled: true }; + us.enterprise_sso = { enabled: true }; + }; + + const withBackupCode = (opts?: Partial) => { + us.attributes.backup_code = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: true, + second_factors: ['backup_code'], + verifications: [], + verify_at_sign_up: false, + ...opts, + }; + }; + + const withAuthenticatorApp = (opts?: Partial) => { + us.attributes.authenticator_app = { + ...emptyAttribute, + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: true, + second_factors: ['totp'], + verifications: [], + verify_at_sign_up: false, + ...opts, + }; + }; + + const withRestrictedMode = () => { + us.sign_up.mode = SIGN_UP_MODES.RESTRICTED; + }; + + const withLegalConsent = () => { + us.sign_up.legal_consent_enabled = true; + }; + + const withWaitlistMode = () => { + us.sign_up.mode = SIGN_UP_MODES.WAITLIST; + }; + + // TODO: Add the rest, consult pkg/generate/auth_config.go + + return { + withEmailAddress, + withEmailLink, + withPhoneNumber, + withUsername, + withWeb3Wallet, + withName, + withPassword, + withPasswordComplexity, + withSocialProvider, + withEnterpriseSso, + withBackupCode, + withAuthenticatorApp, + withPasskey, + withPasskeySettings, + withRestrictedMode, + withLegalConsent, + withWaitlistMode, + }; +}; diff --git a/packages/ui/src/test/fixtures.ts b/packages/ui/src/test/fixtures.ts new file mode 100644 index 00000000000..34bc98dbdca --- /dev/null +++ b/packages/ui/src/test/fixtures.ts @@ -0,0 +1,267 @@ +/* eslint-disable */ +// @ts-nocheck + +import type { + AuthConfigJSON, + ClientJSON, + CommerceSettingsJSON, + DisplayConfigJSON, + EnvironmentJSON, + OrganizationSettingsJSON, + UserJSON, + UserSettingsJSON, +} from '@clerk/shared/types'; + +/** + * Enforces that an array contains ALL keys of T + */ +const containsAllOfType = + () => + >(array: U & ([T] extends [U[number]] ? unknown : 'Invalid')) => + array; + +export const createBaseEnvironmentJSON = (): EnvironmentJSON => { + return { + id: 'env_1', + object: 'environment', + auth_config: createBaseAuthConfig(), + display_config: createBaseDisplayConfig(), + organization_settings: createBaseOrganizationSettings(), + user_settings: createBaseUserSettings(), + commerce_settings: createBaseCommerceSettings(), + meta: { responseHeaders: { country: 'us' } }, + }; +}; + +const createBaseAuthConfig = (): AuthConfigJSON => { + return { + object: 'auth_config', + id: 'aac_1', + single_session_mode: true, + }; +}; + +const createBaseDisplayConfig = (): DisplayConfigJSON => { + return { + object: 'display_config', + id: 'display_config_1', + instance_environment_type: 'production', + application_name: 'TestApp', + theme: { + buttons: { + font_color: '#ffffff', + font_family: '"Inter", sans-serif', + font_weight: '600', + }, + general: { + color: '#6c47ff', + padding: '1em', + box_shadow: '0 2px 8px rgba(0, 0, 0, 0.2)', + font_color: '#151515', + font_family: '"Inter", sans-serif', + border_radius: '0.5em', + background_color: '#ffffff', + label_font_weight: '600', + }, + accounts: { + background_color: '#f2f2f2', + }, + }, + preferred_sign_in_strategy: 'password', + logo_image_url: 'https://images.clerk.com/uploaded/img_logo.png', + favicon_image_url: 'https://images.clerk.com/uploaded/img_favicon.png', + home_url: 'https://dashboard.clerk.com', + sign_in_url: 'https://dashboard.clerk.com/sign-in', + sign_up_url: 'https://dashboard.clerk.com/sign-up', + user_profile_url: 'https://accounts.clerk.com/user', + after_sign_in_url: 'https://dashboard.clerk.com', + after_sign_up_url: 'https://dashboard.clerk.com', + after_sign_out_one_url: 'https://accounts.clerk.com/sign-in/choose', + after_sign_out_all_url: 'https://dashboard.clerk.com/sign-in', + after_switch_session_url: 'https://dashboard.clerk.com', + organization_profile_url: 'https://accounts.clerk.com/organization', + create_organization_url: 'https://accounts.clerk.com/create-organization', + after_leave_organization_url: 'https://dashboard.clerk.com', + after_create_organization_url: 'https://dashboard.clerk.com', + support_email: '', + branded: true, + clerk_js_version: '4', + }; +}; + +const createBaseOrganizationSettings = (): OrganizationSettingsJSON => { + return { + enabled: false, + max_allowed_memberships: 5, + force_organization_selection: false, + actions: { + admin_delete: false, + }, + domains: { + enabled: false, + enrollment_modes: [], + default_role: null, + }, + slug: { + disabled: true, + }, + organization_creation_defaults: { + enabled: false, + }, + } as unknown as OrganizationSettingsJSON; +}; + +const attributes = Object.freeze( + containsAllOfType()([ + 'email_address', + 'phone_number', + 'username', + 'web3_wallet', + 'first_name', + 'last_name', + 'password', + 'authenticator_app', + 'backup_code', + 'passkey', + ]), +); + +const socials = Object.freeze( + containsAllOfType()([ + 'oauth_facebook', + 'oauth_google', + 'oauth_hubspot', + 'oauth_github', + 'oauth_tiktok', + 'oauth_gitlab', + 'oauth_discord', + 'oauth_twitter', + 'oauth_twitch', + 'oauth_linkedin', + 'oauth_linkedin_oidc', + 'oauth_dropbox', + 'oauth_atlassian', + 'oauth_bitbucket', + 'oauth_microsoft', + 'oauth_notion', + 'oauth_apple', + 'oauth_line', + 'oauth_instagram', + 'oauth_coinbase', + 'oauth_spotify', + 'oauth_xero', + 'oauth_box', + 'oauth_slack', + 'oauth_linear', + 'oauth_x', + ]), +); + +const createBaseUserSettings = (): UserSettingsJSON => { + const attributeConfig = Object.fromEntries( + attributes.map(attribute => [ + attribute, + { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + }, + ]), + ) as UserSettingsJSON['attributes']; + + const socialConfig: UserSettingsJSON['social'] = Object.fromEntries( + socials.map(social => [ + social, + { + enabled: false, + required: false, + authenticatable: false, + strategy: social, + }, + ]), + ); + + const passwordSettingsConfig = { + allowed_special_characters: '', + max_length: 0, + min_length: 8, + require_special_char: false, + require_numbers: false, + require_lowercase: false, + require_uppercase: false, + disable_hibp: true, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + } as UserSettingsJSON['password_settings']; + + const passkeySettingsConfig = { + allow_autofill: false, + show_sign_in_button: false, + } as UserSettingsJSON['passkey_settings']; + + return { + attributes: { ...attributeConfig }, + actions: { delete_self: false, create_organization: false }, + social: { ...socialConfig }, + saml: { enabled: false }, + enterprise_sso: { enabled: false }, + sign_in: { + second_factor: { + required: false, + }, + }, + sign_up: { + custom_action_required: false, + progressive: true, + captcha_enabled: false, + disable_hibp: false, + mode: 'public', + }, + restrictions: { + allowlist: { + enabled: false, + }, + blocklist: { + enabled: false, + }, + }, + password_settings: passwordSettingsConfig, + passkey_settings: passkeySettingsConfig, + }; +}; + +export const createBaseClientJSON = (): ClientJSON => { + return {} as ClientJSON; +}; + +const createBaseCommerceSettings = (): CommerceSettingsJSON => { + return { + object: 'commerce_settings', + id: 'commerce_settings_1', + billing: { + user: { + enabled: false, + has_paid_plans: false, + }, + organization: { + enabled: false, + has_paid_plans: false, + }, + stripe_publishable_key: '', + }, + }; +}; + +export const createUserFixture = (): UserJSON => { + return { + first_name: 'Firstname', + last_name: 'Lastname', + image_url: + 'https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NLTmR2TUtFQzN5cUVpMVFjV0UzQjExbF9WUEVOWW5manlLMlVQd0tCSWw9czEwMDAtYyIsInMiOiJkRkowS3dTSkRINndiODE5cXJTUUxxaWF1ZS9QcHdndC84L0lUUlpYNHpnIn0?width=160', + } as UserJSON; +}; diff --git a/packages/ui/src/test/mock-helpers.ts b/packages/ui/src/test/mock-helpers.ts new file mode 100644 index 00000000000..35bcb54a8f6 --- /dev/null +++ b/packages/ui/src/test/mock-helpers.ts @@ -0,0 +1,126 @@ +import type { ActiveSessionResource, LoadedClerk } from '@clerk/shared/types'; +import { type Mocked, vi } from 'vitest'; + +import { QueryClient } from '@/core/query-core'; +import type { RouteContextValue } from '@/ui/router'; + +type FunctionLike = (...args: any) => any; + +type DeepVitestMocked = T extends FunctionLike + ? Mocked + : T extends object + ? { + [k in keyof T]: DeepVitestMocked; + } + : T; + +// Removing vi.Mock type for now, relying on inference +type MockMap = { + [K in { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never }[keyof T]]?: ReturnType; +}; + +const mockProp = (obj: T, k: keyof T, mocks?: MockMap) => { + if (typeof obj[k] === 'function') { + const mockFn = mocks?.[k] ?? vi.fn(); + (obj[k] as unknown as ReturnType) = mockFn; + } +}; + +const mockMethodsOf = | null = any>( + obj: T, + options?: { + exclude: (keyof T)[]; + mocks: MockMap; + }, +) => { + if (!obj) { + return; + } + Object.keys(obj) + .filter(key => !options?.exclude.includes(key as keyof T)) + // Pass the specific MockMap for the object T being mocked + .forEach(k => mockProp(obj, k, options?.mocks)); +}; + +export const mockClerkMethods = (clerk: LoadedClerk): DeepVitestMocked => { + // Cast clerk to any to allow mocking properties + const clerkAny = clerk as any; + + const defaultQueryClient = { + __tag: 'clerk-rq-client' as const, + client: new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // Setting staleTime to Infinity will not cause issues between tests as long as each test + // case has its own wrapper that initializes a Clerk instance with a new QueryClient. + staleTime: Infinity, + }, + }, + }), + }; + + mockMethodsOf(clerkAny); + if (clerkAny.client) { + mockMethodsOf(clerkAny.client.signIn); + mockMethodsOf(clerkAny.client.signUp); + clerkAny.client.sessions?.forEach((session: ActiveSessionResource) => { + const sessionAny = session as any; + mockMethodsOf(sessionAny, { + exclude: ['checkAuthorization'], + mocks: { + // Ensure touch mock matches expected signature if available, otherwise basic mock + touch: vi.fn(() => Promise.resolve(session)), + }, + }); + if (sessionAny.user) { + mockMethodsOf(sessionAny.user); + sessionAny.user.emailAddresses?.forEach((m: any) => mockMethodsOf(m)); + sessionAny.user.phoneNumbers?.forEach((m: any) => mockMethodsOf(m)); + sessionAny.user.externalAccounts?.forEach((m: any) => mockMethodsOf(m)); + sessionAny.user.organizationMemberships?.forEach((m: any) => { + mockMethodsOf(m); + if (m.organization) { + mockMethodsOf(m.organization); + } + }); + sessionAny.user.passkeys?.forEach((m: any) => mockMethodsOf(m)); + } + }); + } + if (clerkAny.billing) { + mockMethodsOf(clerkAny.billing); + } + + // Mock the __internal_queryClient getter property + Object.defineProperty(clerkAny, '__internal_queryClient', { + get: vi.fn(() => defaultQueryClient), + configurable: true, + }); + + mockProp(clerkAny, 'navigate'); + mockProp(clerkAny, 'setActive'); + mockProp(clerkAny, 'redirectWithAuth'); + mockProp(clerkAny, '__internal_navigateWithError'); + return clerkAny as DeepVitestMocked; +}; + +export const mockRouteContextValue = ({ queryString = '' }: Partial>) => { + return { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + queryString, + queryParams: {}, + getMatchData: vi.fn(), + matches: vi.fn(), + baseNavigate: vi.fn(), + navigate: vi.fn(() => Promise.resolve(true)), + resolve: vi.fn((to: string) => new URL(to, 'https://clerk.com')), + refresh: vi.fn(), + params: {}, + } as RouteContextValue; // Keep original type assertion, DeepVitestMocked applied to input only +}; diff --git a/packages/ui/src/test/utils.ts b/packages/ui/src/test/utils.ts new file mode 100644 index 00000000000..24ac50a576f --- /dev/null +++ b/packages/ui/src/test/utils.ts @@ -0,0 +1,72 @@ +import type { RenderOptions } from '@testing-library/react'; +import { render as _render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterAll, beforeAll, describe, vi } from 'vitest'; + +Element.prototype.scrollIntoView = vi.fn(); + +const render = (ui: React.ReactElement, options?: RenderOptions) => { + const user = userEvent.setup({ delay: null }); + return { ..._render(ui, { ...options }), userEvent: user }; +}; + +export const mockNativeRuntime = (fn: () => void) => { + describe('native runtime', () => { + let spyDocument: ReturnType; + let spyNavigator: ReturnType; + + beforeAll(() => { + spyDocument = vi.spyOn(globalThis, 'document', 'get'); + spyDocument.mockReturnValue(undefined); + + spyNavigator = vi.spyOn(globalThis.navigator, 'product', 'get'); + spyNavigator.mockReturnValue('ReactNative'); + }); + + afterAll(() => { + spyDocument.mockRestore(); + spyNavigator.mockRestore(); + }); + + fn(); + }); +}; + +export const mockWebAuthn = (fn: () => void) => { + describe('with WebAuthn', () => { + let originalPublicKeyCredential: any; + beforeAll(() => { + originalPublicKeyCredential = global.PublicKeyCredential; + const publicKeyCredential: any = () => {}; + global.PublicKeyCredential = publicKeyCredential; + publicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () => Promise.resolve(true); + publicKeyCredential.isConditionalMediationAvailable = () => Promise.resolve(true); + }); + + afterAll(() => { + global.PublicKeyCredential = originalPublicKeyCredential; + }); + + fn(); + }); +}; + +// Re-export create-fixtures utilities +export * from './create-fixtures'; + +// Export everything from @testing-library/react except render, then export our custom render +export { + screen, + waitFor, + fireEvent, + act, + cleanup, + renderHook, + type RenderOptions, + type RenderHookOptions, + type RenderHookResult, + type RenderResult, +} from '@testing-library/react'; + +// Export our custom render function that includes userEvent +export { render }; diff --git a/packages/ui/vitest.config.mts b/packages/ui/vitest.config.mts new file mode 100644 index 00000000000..97c41c351ac --- /dev/null +++ b/packages/ui/vitest.config.mts @@ -0,0 +1,72 @@ +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; +import { defineConfig } from 'vitest/config'; + +const clerkJsPath = resolve(__dirname, '../clerk-js/src'); +const uiPath = resolve(__dirname, 'src'); + +function viteSvgMockPlugin() { + return { + name: 'svg-mock', + transform(_code: string, id: string) { + if (id.endsWith('.svg') && process.env.NODE_ENV === 'test') { + return { + code: ` + import React from 'react'; + const SvgMock = React.forwardRef((props, ref) => React.createElement('span', { ref, ...props })); + export default SvgMock; + export { SvgMock as ReactComponent }; + `, + map: null, + }; + } + return undefined; + }, + }; +} + +export default defineConfig({ + plugins: [react({ jsxRuntime: 'automatic', jsxImportSource: '@emotion/react' }), viteSvgMockPlugin()], + define: { + __BUILD_DISABLE_RHC__: JSON.stringify(false), + __BUILD_VARIANT_CHIPS__: JSON.stringify(false), + __PKG_NAME__: JSON.stringify('@clerk/ui'), + __PKG_VERSION__: JSON.stringify('test'), + }, + test: { + environment: 'jsdom', + environmentOptions: { + jsdom: { + resources: 'usable', + }, + }, + globals: false, + include: ['**/*.test.?(c|m)[jt]s?(x)', '**/*.spec.?(c|m)[jt]s?(x)'], + exclude: ['node_modules/**', 'dist/**'], + setupFiles: '../clerk-js/vitest.setup.mts', + testTimeout: 5000, + }, + resolve: { + alias: [ + // UI package paths (local to this package) + { find: /^@\/ui\//, replacement: `${uiPath}/` }, + { find: /^@\/ui$/, replacement: `${uiPath}` }, + // Test utilities - local to UI package + { find: /^@\/test\//, replacement: `${uiPath}/test/` }, + { find: /^@\/test$/, replacement: `${uiPath}/test` }, + // Core modules from clerk-js + { find: /^@\/core\//, replacement: `${clerkJsPath}/core/` }, + { find: /^@\/core$/, replacement: `${clerkJsPath}/core` }, + // UI package utils (must come before clerk-js utils) + { find: '@/utils/errorHandler', replacement: `${uiPath}/utils/errorHandler` }, + { find: '@/utils/factorSorting', replacement: `${uiPath}/utils/factorSorting` }, + { find: '@/utils/formatSafeIdentifier', replacement: `${uiPath}/utils/formatSafeIdentifier` }, + { find: '@/utils/intl', replacement: `${uiPath}/utils/intl` }, + { find: '@/utils/normalizeRoutingOptions', replacement: `${uiPath}/utils/normalizeRoutingOptions` }, + // Utils from clerk-js (needed by clerk-js core modules) + { find: '@/utils', replacement: `${clerkJsPath}/utils` }, + // Catch-all for other @/ imports - UI package + { find: /^@\//, replacement: `${uiPath}/` }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 759fbc78ea8..39827075ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,6 +514,9 @@ importers: '@clerk/testing': specifier: workspace:^ version: link:../testing + '@emotion/react': + specifier: 11.11.1 + version: 11.11.1(@types/react@18.3.26)(react@18.3.1) '@rsdoctor/rspack-plugin': specifier: ^0.4.13 version: 0.4.13(@rspack/core@1.6.1(@swc/helpers@0.5.17))(bufferutil@4.0.9)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.25.12))