diff --git a/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx new file mode 100644 index 00000000000..1807dd4358c --- /dev/null +++ b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx @@ -0,0 +1,591 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { bindCreateFixtures } from '../../utils/vitest/createFixtures'; +import { OTPCodeControl, OTPRoot, useFieldOTP } from '../CodeControl'; +import { withCardStateProvider } from '../contexts'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +// Mock the sleep utility +vi.mock('@/ui/utils/sleep', () => ({ + sleep: vi.fn(() => Promise.resolve()), +})); + +// Helper to create a test component with OTP functionality +const createOTPComponent = ( + onCodeEntryFinished: (code: string, resolve: any, reject: any) => void, + onResendCodeClicked?: () => void, + _options?: { length?: number }, +) => { + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + onResendCodeClicked, + }); + + return ( + + + + ); + }); + + return MockOTPWrapper; +}; + +describe('CodeControl', () => { + describe('OTPCodeControl', () => { + it('renders 6 input fields by default', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + expect(inputs).toHaveLength(6); + }); + + it('renders hidden input for password manager compatibility', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + expect(hiddenInput).toBeInTheDocument(); + expect(hiddenInput).toHaveAttribute('type', 'text'); + expect(hiddenInput).toHaveAttribute('autoComplete', 'one-time-code'); + expect(hiddenInput).toHaveAttribute('inputMode', 'numeric'); + expect(hiddenInput).toHaveAttribute('pattern', '[0-9]{6}'); + expect(hiddenInput).toHaveAttribute('minLength', '6'); + expect(hiddenInput).toHaveAttribute('maxLength', '6'); + expect(hiddenInput).toHaveAttribute('aria-hidden', 'true'); + expect(hiddenInput).toHaveAttribute('tabIndex', '-1'); + }); + + it('autofocuses the first input field', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + // Wait for autofocus to take effect + await waitFor(() => { + const firstInput = container.querySelector('[name="codeInput-0"]'); + expect(firstInput).toHaveFocus(); + }); + }); + + it('allows typing single digits in sequence', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type digits sequentially + await user.type(inputs[0], '1'); + await user.type(inputs[1], '2'); + await user.type(inputs[2], '3'); + + expect(inputs[0]).toHaveValue('1'); + expect(inputs[1]).toHaveValue('2'); + expect(inputs[2]).toHaveValue('3'); + }); + + it('calls onCodeEntryFinished when all 6 digits are entered', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type all 6 digits + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + + it('handles paste operations correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.paste('123456'); + } + + await waitFor(() => { + const inputs = container.querySelectorAll('[data-otp-segment]'); + expect(inputs[0]).toHaveValue('1'); + expect(inputs[1]).toHaveValue('2'); + expect(inputs[2]).toHaveValue('3'); + expect(inputs[3]).toHaveValue('4'); + expect(inputs[4]).toHaveValue('5'); + expect(inputs[5]).toHaveValue('6'); + }); + }); + + it('handles partial paste operations', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const secondInput = container.querySelector('[name="codeInput-1"]'); + + if (secondInput) { + await user.click(secondInput); + await user.paste('234'); + } + + await waitFor(() => { + const inputs = container.querySelectorAll('[data-otp-segment]'); + // Based on the actual behavior, paste fills from position 0 when using userEvent + expect(inputs[0]).toHaveValue('2'); + expect(inputs[1]).toHaveValue('3'); + expect(inputs[2]).toHaveValue('4'); + expect(inputs[3]).toHaveValue(''); + expect(inputs[4]).toHaveValue(''); + expect(inputs[5]).toHaveValue(''); + }); + }); + + it('handles keyboard navigation with arrow keys', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Start at first input + await user.click(inputs[0]); + + // Move right with arrow key + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + + // Move left with arrow key + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + }); + + it('handles backspace to clear current field and move to previous', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type some digits + await user.type(inputs[0], '1'); + await user.type(inputs[1], '2'); + await user.type(inputs[2], '3'); + + // Focus on third input and press backspace + await user.click(inputs[2]); + await user.keyboard('{Backspace}'); + + expect(inputs[2]).toHaveValue(''); + expect(inputs[1]).toHaveFocus(); + }); + + it('prevents space input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.keyboard(' '); + + expect(firstInput).toHaveValue(''); + } + }); + + it('only accepts numeric characters', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.keyboard('a'); + + expect(firstInput).toHaveValue(''); + + await user.keyboard('1'); + expect(firstInput).toHaveValue('1'); + } + }); + + it('handles password manager autofill through hidden input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate password manager filling the hidden input + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '654321' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('6'); + expect(visibleInputs[1]).toHaveValue('5'); + expect(visibleInputs[2]).toHaveValue('4'); + expect(visibleInputs[3]).toHaveValue('3'); + expect(visibleInputs[4]).toHaveValue('2'); + expect(visibleInputs[5]).toHaveValue('1'); + }); + }); + + it('handles partial autofill through hidden input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate partial autofill + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '123' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('1'); + expect(visibleInputs[1]).toHaveValue('2'); + expect(visibleInputs[2]).toHaveValue('3'); + expect(visibleInputs[3]).toHaveValue(''); + expect(visibleInputs[4]).toHaveValue(''); + expect(visibleInputs[5]).toHaveValue(''); + }); + }); + + it('filters non-numeric characters in autofill', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate autofill with mixed characters + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '1a2b3c4d5e6f' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('1'); + expect(visibleInputs[1]).toHaveValue('2'); + expect(visibleInputs[2]).toHaveValue('3'); + expect(visibleInputs[3]).toHaveValue('4'); + expect(visibleInputs[4]).toHaveValue('5'); + expect(visibleInputs[5]).toHaveValue('6'); + }); + }); + + it('focuses first visible input when hidden input is focused', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const firstVisibleInput = container.querySelector('[name="codeInput-0"]'); + + // Focus hidden input + if (hiddenInput) { + fireEvent.focus(hiddenInput); + } + + await waitFor(() => { + expect(firstVisibleInput).toHaveFocus(); + }); + }); + + it('handles disabled state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + + it('handles loading state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + + it('handles error state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const formControl = useFormControl('code', ''); + + // Set error after initial render to avoid infinite re-renders + React.useEffect(() => { + formControl.setError('Invalid code'); + }, []); // Empty dependency array to run only once + + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const otpGroup = container.querySelector('[role="group"]'); + expect(otpGroup).toHaveAttribute('aria-label', 'Verification code input'); + }); + + it('handles first click on mobile devices', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // First click should focus the first input regardless of which input was clicked + await user.click(inputs[3]); + + await waitFor(() => { + expect(inputs[0]).toHaveFocus(); + }); + + // Second click should focus the clicked input + await user.click(inputs[3]); + + await waitFor(() => { + expect(inputs[3]).toHaveFocus(); + }); + }); + + it('updates hidden input when visible inputs change', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Type some digits + await user.type(visibleInputs[0], '1'); + await user.type(visibleInputs[1], '2'); + await user.type(visibleInputs[2], '3'); + + await waitFor(() => { + expect(hiddenInput).toHaveValue('123'); + }); + }); + + it('has correct accessibility attributes', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + const group = container.querySelector('[role="group"]'); + + expect(group).toHaveAttribute('aria-label', 'Verification code input'); + + inputs.forEach((input, index) => { + expect(input).toHaveAttribute( + 'aria-label', + index === 0 ? 'Enter verification code. Digit 1' : `Digit ${index + 1}`, + ); + expect(input).toHaveAttribute('inputMode', 'numeric'); + expect(input).toHaveAttribute('pattern', '[0-9]'); + expect(input).toHaveAttribute('maxLength', '1'); + }); + }); + + it('prevents password manager data attributes', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toHaveAttribute('data-1p-ignore'); + expect(input).toHaveAttribute('data-lpignore', 'true'); + }); + }); + }); + + describe('useFieldOTP hook', () => { + it('handles successful code entry', async () => { + const { wrapper } = await createFixtures(); + const _onResolve = vi.fn(); + const onCodeEntryFinished = vi.fn((code, resolve) => { + resolve('success'); + }); + + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Enter complete code + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + + it('handles code entry errors', async () => { + const { wrapper } = await createFixtures(); + + const onCodeEntryFinished = vi.fn((_, __, reject) => { + // Simulate synchronous error handling - just call reject + const error = new Error('Invalid code'); + reject(error); + }); + + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Enter complete code + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + }); +});