fix(validation): added validation for inputs for signup & email, added tests (#438)

* add validation for signup/login fields

* added validation for inputs for signup & email, added tests

* acknowledged PR comments
This commit is contained in:
Waleed Latif
2025-05-29 23:45:33 -07:00
committed by GitHub
parent b2450530d1
commit 50cbc890c2
9 changed files with 1056 additions and 36 deletions

View File

@@ -0,0 +1,251 @@
/**
* @vitest-environment jsdom
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@/lib/auth-client'
import LoginPage from './login-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signIn: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render login form with all required elements', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<LoginPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<LoginPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('password123')
})
it('should show loading state during form submission', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
expect(screen.getByText('Signing in...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call signIn with correct credentials', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignIn).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'password123',
callbackURL: '/w',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should handle authentication errors', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
mockSignIn.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'INVALID_CREDENTIALS',
message: 'Invalid credentials',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Invalid credentials' })
})
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
})
})
})
describe('Forgot Password', () => {
it('should open forgot password dialog', () => {
render(<LoginPage {...defaultProps} />)
const forgotPasswordButton = screen.getByText(/forgot password/i)
fireEvent.click(forgotPasswordButton)
expect(screen.getByText('Reset Password')).toBeInTheDocument()
})
})
describe('URL Parameters', () => {
it('should handle invite flow parameter in signup link', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'callbackUrl') return '/invite/123'
return null
})
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular signup link when no invite flow', () => {
render(<LoginPage {...defaultProps} />)
const signupLink = screen.getByText(/sign up/i)
expect(signupLink).toHaveAttribute('href', '/signup')
})
})
describe('Email Verification Flow', () => {
it('should redirect to verification page when email not verified', async () => {
const mockSignIn = vi.mocked(client.signIn.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignIn.mockRejectedValue({
message: 'Email not verified',
code: 'EMAIL_NOT_VERIFIED',
})
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<LoginPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'password123' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSendOtp).toHaveBeenCalledWith({
email: 'test@example.com',
type: 'email-verification',
})
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
})
})
})
})

View File

@@ -10,10 +10,37 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
const logger = createLogger('LoginForm')
const EMAIL_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Email is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Email cannot be empty.',
},
basicFormat: {
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address.',
},
}
const PASSWORD_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Password is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Password cannot be empty.',
},
}
// Validate callback URL to prevent open redirect vulnerabilities
const validateCallbackUrl = (url: string): boolean => {
try {
@@ -35,6 +62,44 @@ const validateCallbackUrl = (url: string): boolean => {
}
}
// Validate email and return array of error messages
const validateEmail = (emailValue: string): string[] => {
const errors: string[] = []
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
}
return errors
}
// Validate password and return array of error messages
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
return errors
}
export default function LoginPage({
githubAvailable,
googleAvailable,
@@ -66,6 +131,11 @@ export default function LoginPage({
message: string
}>({ type: null, message: '' })
// Email validation state
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
// Extract URL parameters after component mounts to avoid SSR issues
useEffect(() => {
setMounted(true)
@@ -101,6 +171,26 @@ export default function LoginPage({
}
}, [forgotPasswordEmail, forgotPasswordOpen])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
// Silently validate but don't show errors until submit
const errors = validateEmail(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
// Silently validate but don't show errors until submit
const errors = validatePassword(newPassword)
setPasswordErrors(errors)
setShowValidationError(false)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
@@ -108,6 +198,22 @@ export default function LoginPage({
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
// Validate email on submit
const emailValidationErrors = validateEmail(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
// Validate password on submit
const passwordValidationErrors = validatePassword(password)
setPasswordErrors(passwordValidationErrors)
setShowValidationError(passwordValidationErrors.length > 0)
// If there are validation errors, stop submission
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
setIsLoading(false)
return
}
try {
// Final validation before submission
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/w'
@@ -290,12 +396,25 @@ export default function LoginPage({
name='email'
placeholder='Enter your email'
required
type='email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
className='border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60'
value={email}
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showEmailValidationError &&
emailErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
@@ -321,14 +440,13 @@ export default function LoginPage({
autoCorrect='off'
placeholder='Enter your password'
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (showValidationError) {
setShowValidationError(false)
setPasswordErrors([])
}
}}
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
onChange={handlePasswordChange}
className={cn(
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
showValidationError &&
passwordErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
<button
type='button'

View File

@@ -0,0 +1,442 @@
/**
* @vitest-environment jsdom
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@/lib/auth-client'
import SignupPage from './signup-form'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/lib/auth-client', () => ({
client: {
signUp: {
email: vi.fn(),
},
emailOtp: {
sendVerificationOtp: vi.fn(),
},
},
}))
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
}))
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}
const mockSearchParams = {
get: vi.fn(),
}
describe('SignupPage', () => {
beforeEach(() => {
vi.clearAllMocks()
;(useRouter as any).mockReturnValue(mockRouter)
;(useSearchParams as any).mockReturnValue(mockSearchParams)
mockSearchParams.get.mockReturnValue(null)
})
const defaultProps = {
githubAvailable: true,
googleAvailable: true,
isProduction: false,
}
describe('Basic Rendering', () => {
it('should render signup form with all required elements', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
})
it('should render social login buttons', () => {
render(<SignupPage {...defaultProps} />)
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
})
})
describe('Password Visibility Toggle', () => {
it('should toggle password visibility when button is clicked', () => {
render(<SignupPage {...defaultProps} />)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const toggleButton = screen.getByLabelText(/show password/i)
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(toggleButton)
expect(passwordInput).toHaveAttribute('type', 'password')
})
})
describe('Form Interaction', () => {
it('should allow users to type in form fields', () => {
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
expect(nameInput).toHaveValue('John Doe')
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('Password123!')
})
it('should show loading state during form submission', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
expect(screen.getByText('Creating account...')).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call signUp with correct credentials and trimmed name', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use valid input that passes all validation rules
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'Password123!',
name: 'John Doe',
},
expect.objectContaining({
onError: expect.any(Function),
})
)
})
})
it('should prevent submission with invalid name validation', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
// Use name with leading/trailing spaces which should fail validation
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
// Should not call signUp because validation failed
expect(mockSignUp).not.toHaveBeenCalled()
// Should show validation error
await waitFor(() => {
expect(
screen.getByText(
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
)
).toBeInTheDocument()
})
})
it('should redirect to verification page after successful signup', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
mockSendOtp.mockResolvedValue({ data: null, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockSendOtp).toHaveBeenCalledWith({
email: 'test@example.com',
type: 'email-verification',
})
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
})
})
it('should handle signup errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'USER_ALREADY_EXISTS',
message: 'User already exists',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'User already exists' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
it('should show warning for names that would be truncated (over 100 characters)', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
const longName = 'a'.repeat(101) // 101 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: longName } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
})
// Ensure signUp was not called
expect(mockSignUp).not.toHaveBeenCalled()
})
it('should handle names exactly at 100 characters without warning', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation(
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
)
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: exactLengthName } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
fireEvent.click(submitButton)
// Should not show truncation warning
await waitFor(() => {
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
})
// Should proceed with form submission
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith(
{
email: 'test@example.com',
password: 'ValidPass123!',
name: exactLengthName,
},
expect.any(Object)
)
})
})
it('should handle names exactly at validation errors', async () => {
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockImplementation((credentials, options) => {
if (options?.onError) {
options.onError({
error: {
code: 'NAME_VALIDATION_ERROR',
message: 'Name validation error',
} as any,
response: {} as any,
request: {} as any,
} as any)
}
return Promise.resolve({ data: null, error: 'Name validation error' })
})
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
})
})
})
describe('URL Parameters', () => {
it('should prefill email from URL parameter', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'email') return 'prefilled@example.com'
return null
})
render(<SignupPage {...defaultProps} />)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
expect(emailInput).toHaveValue('prefilled@example.com')
})
it('should handle invite flow redirect', async () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'redirect') return '/invite/123'
if (param === 'invite_flow') return 'true'
return null
})
const mockSignUp = vi.mocked(client.signUp.email)
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
render(<SignupPage {...defaultProps} />)
const nameInput = screen.getByPlaceholderText(/enter your name/i)
const emailInput = screen.getByPlaceholderText(/enter your email/i)
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
const submitButton = screen.getByRole('button', { name: /create account/i })
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockRouter.push).toHaveBeenCalledWith('/invite/123')
})
})
it('should handle waitlist token verification', async () => {
const mockFetch = vi.mocked(global.fetch)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
email: 'waitlist@example.com',
}),
} as Response)
mockSearchParams.get.mockImplementation((param) => {
if (param === 'token') return 'waitlist-token-123'
return null
})
render(<SignupPage {...defaultProps} />)
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/auth/verify-waitlist-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: 'waitlist-token-123' }),
})
})
})
it('should link to login with invite flow parameters', () => {
mockSearchParams.get.mockImplementation((param) => {
if (param === 'invite_flow') return 'true'
if (param === 'redirect') return '/invite/123'
return null
})
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
})
it('should default to regular login link when no invite flow', () => {
render(<SignupPage {...defaultProps} />)
const loginLink = screen.getByText(/sign in/i)
expect(loginLink).toHaveAttribute('href', '/login')
})
})
})

View File

@@ -28,6 +28,56 @@ const PASSWORD_VALIDATIONS = {
},
}
const NAME_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Name is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Name cannot be empty.',
},
validCharacters: {
regex: /^[\p{L}\s\-']+$/u,
message: 'Name can only contain letters, spaces, hyphens, and apostrophes.',
},
noConsecutiveSpaces: {
regex: /^(?!.*\s\s).*$/,
message: 'Name cannot contain consecutive spaces.',
},
noLeadingTrailingSpaces: {
test: (value: string) => value === value.trim(),
message: 'Name cannot start or end with spaces.',
},
}
const EMAIL_VALIDATIONS = {
required: {
test: (value: string) => Boolean(value && typeof value === 'string'),
message: 'Email is required.',
},
notEmpty: {
test: (value: string) => value.trim().length > 0,
message: 'Email cannot be empty.',
},
maxLength: {
test: (value: string) => value.length <= 254,
message: 'Email must be less than 254 characters.',
},
basicFormat: {
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address.',
},
noSpaces: {
regex: /^[^\s]*$/,
message: 'Email cannot contain spaces.',
},
validStart: {
regex: /^[a-zA-Z0-9]/,
message: 'Email must start with a letter or number.',
},
}
function SignupFormContent({
githubAvailable,
googleAvailable,
@@ -47,10 +97,17 @@ function SignupFormContent({
const [showValidationError, setShowValidationError] = useState(false)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [waitlistToken, setWaitlistToken] = useState('')
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
// Name validation state
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
const [showNameValidationError, setShowNameValidationError] = useState(false)
useEffect(() => {
setMounted(true)
const emailParam = searchParams.get('email')
@@ -110,7 +167,6 @@ function SignupFormContent({
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
// Check each validation criteria
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.minLength.message)
}
@@ -134,6 +190,68 @@ function SignupFormContent({
return errors
}
// Validate name and return array of error messages
const validateName = (nameValue: string): string[] => {
const errors: string[] = []
if (!NAME_VALIDATIONS.required.test(nameValue)) {
errors.push(NAME_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
errors.push(NAME_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
errors.push(NAME_VALIDATIONS.validCharacters.message)
}
if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
}
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
}
return errors
}
// Validate email and return array of error messages
const validateEmail = (emailValue: string): string[] => {
const errors: string[] = []
if (!EMAIL_VALIDATIONS.required.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.required.message)
return errors // Return early for required field
}
if (!EMAIL_VALIDATIONS.notEmpty.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.notEmpty.message)
return errors // Return early for empty field
}
if (!EMAIL_VALIDATIONS.maxLength.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.maxLength.message)
}
if (!EMAIL_VALIDATIONS.noSpaces.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.noSpaces.message)
}
if (!EMAIL_VALIDATIONS.validStart.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.validStart.message)
}
if (!EMAIL_VALIDATIONS.basicFormat.regex.test(emailValue)) {
errors.push(EMAIL_VALIDATIONS.basicFormat.message)
}
return errors
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
@@ -144,9 +262,26 @@ function SignupFormContent({
setShowValidationError(false)
}
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value
setName(newName)
// Silently validate but don't show errors until submit
const errors = validateName(newName)
setNameErrors(errors)
setShowNameValidationError(false)
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
// Clear any previous email errors when the user starts typing
const newEmail = e.target.value
setEmail(newEmail)
// Silently validate but don't show errors until submit
const errors = validateEmail(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
// Clear any previous server-side email errors when the user starts typing
if (emailError) {
setEmailError('')
}
@@ -161,6 +296,16 @@ function SignupFormContent({
const passwordValue = formData.get('password') as string
const name = formData.get('name') as string
// Validate name on submit
const nameValidationErrors = validateName(name)
setNameErrors(nameValidationErrors)
setShowNameValidationError(nameValidationErrors.length > 0)
// Validate email on submit
const emailValidationErrors = validateEmail(emailValue)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
// Validate password on submit
const errors = validatePassword(passwordValue)
setPasswordErrors(errors)
@@ -169,19 +314,44 @@ function SignupFormContent({
setShowValidationError(errors.length > 0)
try {
if (errors.length > 0) {
// Show first error as notification
setPasswordErrors([errors[0]])
setShowValidationError(true)
if (
nameValidationErrors.length > 0 ||
emailValidationErrors.length > 0 ||
errors.length > 0
) {
// Prioritize name errors first, then email errors, then password errors
if (nameValidationErrors.length > 0) {
setNameErrors([nameValidationErrors[0]])
setShowNameValidationError(true)
}
if (emailValidationErrors.length > 0) {
setEmailErrors([emailValidationErrors[0]])
setShowEmailValidationError(true)
}
if (errors.length > 0) {
setPasswordErrors([errors[0]])
setShowValidationError(true)
}
setIsLoading(false)
return
}
// Check if name will be truncated and warn user
const trimmedName = name.trim()
if (trimmedName.length > 100) {
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
setShowNameValidationError(true)
setIsLoading(false)
return
}
const sanitizedName = trimmedName
const response = await client.signUp.email(
{
email: emailValue,
password: passwordValue,
name,
name: sanitizedName,
},
{
onError: (ctx) => {
@@ -311,12 +481,26 @@ function SignupFormContent({
id='name'
name='name'
placeholder='Enter your name'
required
type='text'
autoCapitalize='words'
autoComplete='name'
className='border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60'
title='Name can only contain letters, spaces, hyphens, and apostrophes'
value={name}
onChange={handleNameChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
showNameValidationError &&
nameErrors.length > 0 &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{showNameValidationError && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='text-neutral-300'>
@@ -326,8 +510,6 @@ function SignupFormContent({
id='email'
name='email'
placeholder='Enter your email'
required
type='email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
@@ -335,10 +517,18 @@ function SignupFormContent({
onChange={handleEmailChange}
className={cn(
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
emailError && 'border-red-500 focus-visible:ring-red-500'
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
'border-red-500 focus-visible:ring-red-500'
)}
/>
{emailError && (
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
{emailError && !showEmailValidationError && (
<div className='mt-1 text-red-400 text-xs'>
<p>{emailError}</p>
</div>
@@ -352,7 +542,6 @@ function SignupFormContent({
<Input
id='password'
name='password'
required
type={showPassword ? 'text' : 'password'}
autoCapitalize='none'
autoComplete='new-password'

View File

@@ -1,5 +1,5 @@
/**
* @vitest-environment jsdom
* @vitest-environment node
*
* Executor Class Unit Tests
*
@@ -22,7 +22,19 @@ import {
import { Executor } from './index'
import type { BlockLog } from './types'
// Mock the logger
vi.mock('@/stores/execution/store', () => ({
useExecutionStore: {
getState: vi.fn(() => ({
setIsExecuting: vi.fn(),
setIsDebugging: vi.fn(),
setPendingBlocks: vi.fn(),
reset: vi.fn(),
setActiveBlocks: vi.fn(),
})),
setState: vi.fn(),
},
}))
vi.mock('@/lib/logs/console-logger', () => ({
createLogger: () => ({
error: vi.fn(),
@@ -35,7 +47,6 @@ vi.mock('@/lib/logs/console-logger', () => ({
describe('Executor', () => {
beforeEach(() => {
vi.clearAllMocks()
// Setup all standard mocks by default
setupAllMocks()
})
@@ -88,7 +99,6 @@ describe('Executor', () => {
*/
describe('workflow validation', () => {
test('should validate workflow on initialization', () => {
// Create a spy for the validateWorkflow method
const validateSpy = vi.spyOn(Executor.prototype as any, 'validateWorkflow')
const workflow = createMinimalWorkflow()
@@ -101,7 +111,6 @@ describe('Executor', () => {
const workflow = createMinimalWorkflow()
const executor = new Executor(workflow)
// Create a spy for the validateWorkflow method and reset the mock
const validateSpy = vi.spyOn(executor as any, 'validateWorkflow')
validateSpy.mockClear()

View File

@@ -107,8 +107,9 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jsdom": "21.1.7",
"@types/lodash": "^4.17.16",
"@types/node": "^22",
"@types/prismjs": "^1.26.5",

View File

@@ -16,6 +16,7 @@ export default defineConfig({
environment: 'node',
include: ['**/*.test.{ts,tsx}'],
exclude: [...configDefaults.exclude, '**/node_modules/**', '**/dist/**'],
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': resolve(__dirname, './'),
},

View File

@@ -1,5 +1,5 @@
import { afterAll, vi } from 'vitest'
import '@testing-library/jest-dom'
import '@testing-library/jest-dom/vitest'
// Mock global fetch
global.fetch = vi.fn(() =>
@@ -35,6 +35,8 @@ vi.mock('@/stores/execution/store', () => ({
useExecutionStore: {
getState: vi.fn().mockReturnValue({
setIsExecuting: vi.fn(),
setIsDebugging: vi.fn(),
setPendingBlocks: vi.fn(),
reset: vi.fn(),
setActiveBlocks: vi.fn(),
}),

View File

@@ -137,8 +137,9 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jsdom": "21.1.7",
"@types/lodash": "^4.17.16",
"@types/node": "^22",
"@types/prismjs": "^1.26.5",
@@ -1251,6 +1252,8 @@
"@types/jest": ["@types/jest@26.0.24", "", { "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" } }, "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w=="],
"@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
@@ -1285,6 +1288,8 @@
"@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/yargs": ["@types/yargs@15.0.19", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA=="],
@@ -1693,7 +1698,7 @@
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
@@ -3217,6 +3222,8 @@
"cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
@@ -3247,6 +3254,8 @@
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"jaeger-client/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@@ -3305,8 +3314,6 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
"pdf-parse/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],