mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
Replace layout/spacing utility string assertions with stable contract pins (data-slot, data-retracted, exported constant identity, ARIA). Delete cosmetic-only padding/bleed/grid pins per plan remover-pins-classname. Closes #240 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
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<string, string>()
|
|
|
|
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<string, string[]>()
|
|
|
|
/** 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<string>()): Set<string> => {
|
|
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<string>()
|
|
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<typeof hydrateRoot> | 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()
|
|
})
|
|
})
|