import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, extname, resolve } from 'node:path' import { act, createElement } from 'react' import { hydrateRoot } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loginAction: vi.fn(), })) vi.mock('@/app/(campaign)/campanha/actions/auth', () => ({ loginCampaign: mocks.loginAction, loginCampaignFormAction: mocks.loginAction, })) import { LoginForm } from '@/app/(campaign)/campanha/login/LoginForm' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/Table' const sourceRoot = resolve(process.cwd(), 'src') const localImportPattern = /(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g /** * The client graphs overlap heavily — the ui kit sits in almost every one — so * each file is read and scanned once for the whole suite instead of once per * entry that reaches it. */ const sourceCache = new Map() const readSource = (file: string): string => { const cached = sourceCache.get(file) if (cached !== undefined) return cached const source = readFileSync(file, 'utf8') sourceCache.set(file, source) return source } const resolveLocalModule = (importer: string, specifier: string): string | null => { const base = specifier.startsWith('@/') ? resolve(sourceRoot, specifier.slice(2)) : specifier.startsWith('.') ? resolve(dirname(importer), specifier) : null if (!base) return null for (const candidate of [ base, `${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts'), resolve(base, 'index.tsx'), ]) { if (existsSync(candidate)) return candidate } return null } const runtimeDependencyCache = new Map() /** Local modules a file pulls in for VALUES — `import type` never reaches the client. */ const runtimeDependenciesOf = (file: string): string[] => { const cached = runtimeDependencyCache.get(file) if (cached) return cached const dependencies: string[] = [] for (const match of readSource(file).matchAll(localImportPattern)) { if (/^import\s+type\b/.test(match[0])) continue const dependency = resolveLocalModule(file, match[1]!) // A `'use server'` module is a network boundary, not a bundled import. if (dependency && !/^['"]use server['"]/.test(readSource(dependency))) { dependencies.push(dependency) } } runtimeDependencyCache.set(file, dependencies) return dependencies } const collectLocalDependencyGraph = (entry: string, visited = new Set()): Set => { if (visited.has(entry)) return visited visited.add(entry) for (const dependency of runtimeDependenciesOf(entry)) { collectLocalDependencyGraph(dependency, visited) } return visited } const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } beforeAll(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true vi.stubGlobal( 'ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }, ) }) afterAll(() => { delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT vi.unstubAllGlobals() }) afterEach(() => { cleanup() vi.clearAllMocks() }) describe('campaign login composition', () => { it('keeps client validation libraries out of the login graph', () => { const source = readFileSync( resolve(process.cwd(), 'src/app/(campaign)/campanha/login/LoginForm.tsx'), 'utf8', ) expect(source).not.toContain('@hookform/resolvers') expect(source).not.toContain('react-hook-form') expect(source).not.toContain('campaignLoginSchema') }) it('uses native constraints and password-manager metadata', () => { render(createElement(LoginForm)) const identifier = screen.getByLabelText('E-mail ou celular') const password = screen.getByLabelText('Senha') const rememberMe = screen.getByLabelText('Lembrar de mim neste dispositivo') const submit = screen.getByRole('button', { name: 'Entrar' }) expect(screen.getByRole('heading', { level: 1, name: 'Entrar na campanha' })).toBeTruthy() expect(identifier.getAttribute('name')).toBe('identifier') expect(identifier.hasAttribute('required')).toBe(true) expect(identifier.getAttribute('autocomplete')).toBe('username') expect(identifier.getAttribute('inputmode')).toBe('text') expect(password.getAttribute('name')).toBe('password') expect(password.hasAttribute('required')).toBe(true) expect(password.getAttribute('autocomplete')).toBe('current-password') expect(rememberMe.getAttribute('aria-checked')).toBe('false') expect(rememberMe.getAttribute('aria-describedby')).toBe('remember-me-description') expect( screen.getByText( 'Em aparelho pessoal, fique conectado por 14 dias. Desmarcado, o acesso dura 8 horas.', ), ).toBeTruthy() expect(submit.getAttribute('type')).toBe('submit') }) it('switches identifier inputMode toward phone or email as the user types', () => { render(createElement(LoginForm)) const identifier = screen.getByLabelText('E-mail ou celular') fireEvent.change(identifier, { target: { value: '(71) 9' } }) expect(identifier.getAttribute('inputmode')).toBe('tel') fireEvent.change(identifier, { target: { value: 'staff@' } }) expect(identifier.getAttribute('inputmode')).toBe('email') }) it('links auth errors to both credential fields and shows leadership recovery copy', async () => { mocks.loginAction.mockResolvedValue({ message: 'E-mail, celular ou senha inválidos.' }) render(createElement(LoginForm)) fireEvent.change(screen.getByLabelText('E-mail ou celular'), { target: { value: '71' }, }) fireEvent.change(screen.getByLabelText('Senha'), { target: { value: 'wrong' }, }) fireEvent.submit(screen.getByRole('button', { name: 'Entrar' }).closest('form')!) const alert = await screen.findByRole('alert') expect(alert.id).toBe('login-credentials-error') expect(alert.textContent).toContain('E-mail, celular ou senha inválidos.') expect(screen.getByLabelText('E-mail ou celular').getAttribute('aria-invalid')).toBe('true') expect(screen.getByLabelText('Senha').getAttribute('aria-invalid')).toBe('true') expect(screen.getByLabelText('E-mail ou celular').getAttribute('aria-describedby')).toBe( 'login-credentials-error', ) expect(screen.getByLabelText('Senha').getAttribute('aria-describedby')).toBe( 'login-credentials-error', ) expect( screen.getByText(/Conta só com celular\? Peça um novo convite ao coordenador/), ).toBeTruthy() }) it('shows the pending spinner and announces an action error', async () => { let resolveAction: ((value: { message: string }) => void) | undefined mocks.loginAction.mockImplementation( () => new Promise<{ message: string }>((resolve) => { resolveAction = resolve }), ) render(createElement(LoginForm)) fireEvent.change(screen.getByLabelText('E-mail ou celular'), { target: { value: 'staff@example.com' }, }) fireEvent.change(screen.getByLabelText('Senha'), { target: { value: ' wrong-password ' }, }) fireEvent.submit(screen.getByRole('button', { name: 'Entrar' }).closest('form')!) const pendingButton = await screen.findByRole('button', { name: 'Entrando...' }) const submittedFormData = mocks.loginAction.mock.calls[0]?.[1] expect(submittedFormData).toBeInstanceOf(FormData) expect((submittedFormData as FormData).get('identifier')).toBe('staff@example.com') expect((submittedFormData as FormData).get('password')).toBe(' wrong-password ') expect(pendingButton.querySelector('[data-slot="spinner"]')).not.toBeNull() expect((pendingButton as HTMLButtonElement).disabled).toBe(true) await act(async () => { resolveAction?.({ message: 'E-mail, celular ou senha inválidos.' }) }) await waitFor(() => expect(screen.getByRole('alert').textContent).toContain( 'E-mail, celular ou senha inválidos.', ), ) }) }) describe('campaign client module boundaries', () => { it('keeps every client dependency graph free of server FormData and Node-only modules', () => { const clientEntries = readdirSync(sourceRoot, { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) .map((entry) => resolve(entry.parentPath, entry.name)) .filter((file) => /^['"]use client['"]/.test(readSource(file))) const reachable = new Set() for (const entry of clientEntries) { for (const dependency of collectLocalDependencyGraph(entry)) reachable.add(dependency) } for (const dependency of reachable) { expect(dependency).not.toBe(resolve(sourceRoot, 'lib/formData.ts')) expect(readSource(dependency), `${dependency} entered the client graph`).not.toMatch( /(?:from\s+['"]node:|require\(['"]node:|\bBuffer\b)/, ) } }) }) describe('static table composition', () => { const table = createElement( Table, null, createElement( TableHeader, null, createElement(TableRow, null, createElement(TableHead, null, 'Nome')), ), createElement( TableBody, null, createElement(TableRow, null, createElement(TableCell, null, 'Município Centro')), ), ) it('has no client-only directive or browser dependencies', () => { const source = readFileSync(resolve(process.cwd(), 'src/components/ui/Table.tsx'), 'utf8') expect(source).not.toMatch(/^['"]use client['"]/) expect(source).not.toMatch(/\buse(State|Effect|LayoutEffect|Ref|Memo|Callback)\b/) expect(source).not.toMatch(/\b(window|document|navigator)\b/) }) it('renders on the server and hydrates without mismatches', async () => { const html = renderToStaticMarkup(table) const container = document.createElement('div') container.innerHTML = html const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) let root: ReturnType | undefined await act(async () => { root = hydrateRoot(container, table) }) expect(container.querySelector('table')).not.toBeNull() expect(container.textContent).toContain('Município Centro') expect(consoleError).not.toHaveBeenCalled() await act(async () => root?.unmount()) consoleError.mockRestore() }) })