chore: document and stabilize vitest browser setup

This commit is contained in:
Esdras Renan 2025-10-22 17:19:12 -03:00
parent 42942350dc
commit eee0f432e7
12 changed files with 1238 additions and 325 deletions

View file

@ -46,6 +46,7 @@ Aplicação Next.js 15 com Convex e Better Auth para gestão de tickets da Rever
- Índice de docs: `docs/README.md`
- Operações (produção): `docs/operations.md`
- Guia de DEV: `docs/DEV.md`
- Testes automatizados (Vitest/Playwright): `docs/testes-vitest.md`
- Stack Swarm: `stack.yml` (roteado por Traefik, rede `traefik_public`).
### Variáveis de ambiente

70
docs/testes-vitest.md Normal file
View file

@ -0,0 +1,70 @@
# Guia de Testes com Vitest 4
Este documento resume a configuração atual de testes e como aproveitá-la para automatizar novas verificações.
## Comandos principais
- `pnpm test` → roda a suíte unitária em ambiente Node/JSdom.
- `pnpm test:browser` → executa os testes de navegador via Playwright (Chromium headless).
- `pnpm test:all` → executa as duas suítes de uma vez (requer Playwright instalado).
> Sempre que adicionar novos testes, priorize mantê-los compatíveis com esses dois ambientes.
## Pré-requisitos
1. Dependências JavaScript já estão listadas em `package.json` (`vitest`, `@vitest/browser-playwright`, `playwright`, `jsdom`, etc.).
2. Baixe os binários do Playwright uma vez:
```bash
pnpm exec playwright install chromium
```
3. Em ambientes Linux “puros”, instale as bibliotecas de sistema recomendadas:
```bash
sudo apt-get install libnspr4 libnss3 libasound2t64
# ou
sudo pnpm exec playwright install-deps
```
Se o Playwright avisar sobre dependências ausentes ao rodar `pnpm test:browser`, instale-as e repita o comando.
## Estrutura de setup
- `vitest.setup.node.ts` → executado apenas na suíte Node. Aqui é seguro acessar `process`, configurar variáveis de ambiente, carregar `tsconfig-paths/register`, etc.
- `tests/setup.browser.ts` → setup vazio para a suíte de navegador. Não use `process` ou APIs do Node aqui; adicione polyfills/mocks específicos do browser quando necessário.
O arquivo `vitest.config.mts` seleciona automaticamente o setup correto com base na env `VITEST_BROWSER`.
```ts
setupFiles: process.env.VITEST_BROWSER
? ["./tests/setup.browser.ts"]
: ["./vitest.setup.node.ts"],
```
## Boas práticas para novos testes
- **Aliases (`@/`)**: continuam funcionando em ambos os ambientes graças ao `vite-tsconfig-paths`.
- **Variáveis de ambiente no browser**: use `import.meta.env.VITE_*`. Evite `process.env` no código que será executado no navegador.
- **Mocks Playwright**: para testes de browser, use os helpers de `vitest/browser`. Exemplo:
```ts
import { expect, test } from "vitest"
import { page } from "vitest/browser"
test("exemplo", async () => {
await page.goto("https://example.com")
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})
```
No nosso exemplo atual (`tests/browser/example.browser.test.ts`) manipulamos o DOM diretamente e geramos screenshots com `expect(...).toMatchScreenshot(...)`.
- **Snapshots visuais**: os arquivos de referência ficam em `tests/browser/__screenshots__/`. Ao criar ou atualizar um snapshot, revise e commite apenas se estiver correto.
- **Mocks que dependem de `vi.fn()`**: quando mockar classes/constructores (ex.: `ConvexHttpClient`), use funções nomeadas ou `class` ao definir a implementação para evitar os erros do Vitest 4 (“requires function or class”).
## Fluxo sugerido no dia a dia
1. Rode `pnpm test` localmente antes de abrir PRs.
2. Para alterações visuais/lógicas que afetem UI, adicione/atualize um teste em `tests/browser` e valide com `pnpm test:browser`.
3. Se novos snapshots forem criados ou alterados, confirme visualmente e inclua os arquivos em commit.
4. Para tarefas de automação futuras (por exemplo, smoke-tests que renderizam componentes críticos), utilize a mesma estrutura:
- Setup mínimo no `tests/setup.browser.ts`.
- Testes localizados em `tests/browser/**.browser.test.ts`.
- Utilização de Playwright para interagir com a UI e gerar screenshots/asserts.
Seguindo este guia, conseguimos manter a suíte rápida no ambiente Node e, ao mesmo tempo, aproveitar o modo browser do Vitest 4 para validações visuais e regressões de UI. Quilas regressões detectadas automaticamente economizam tempo de QA manual e agilizam o ciclo de entrega.***

View file

@ -10,6 +10,8 @@
"prisma:generate": "prisma generate",
"convex:dev": "convex dev",
"test": "vitest --run --passWithNoTests",
"test:browser": "cross-env VITEST_BROWSER=true vitest --run --browser.headless tests/browser/example.browser.test.ts --passWithNoTests",
"test:all": "cross-env VITEST_BROWSER=true vitest --run --passWithNoTests",
"auth:seed": "node scripts/seed-auth.mjs",
"queues:ensure": "node scripts/ensure-default-queues.mjs",
"desktop:dev": "pnpm --filter appsdesktop tauri dev",
@ -80,14 +82,20 @@
"@types/react-dom": "^18",
"@types/sanitize-html": "^2.16.0",
"@types/three": "^0.180.0",
"@vitest/browser-playwright": "^4.0.1",
"better-sqlite3": "^12.4.1",
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "^16.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^27.0.1",
"playwright": "^1.56.1",
"prisma": "^6.16.2",
"tailwindcss": "^4",
"tsconfig-paths": "^4.2.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5",
"vitest": "^2.1.4"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.1"
}
}

1338
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,17 @@ const mutationMock = vi.fn()
const deleteManyMock = vi.fn()
const assertAuthenticatedSession = vi.fn()
vi.mock("convex/browser", () => ({
ConvexHttpClient: vi.fn().mockImplementation(() => ({
mutation: mutationMock,
})),
}))
vi.mock("convex/browser", () => {
const ConvexHttpClient = vi.fn(function ConvexHttpClientMock() {
return {
mutation: mutationMock,
}
})
return {
ConvexHttpClient,
}
})
vi.mock("@/lib/prisma", () => ({
prisma: {
@ -32,6 +38,12 @@ describe("POST /api/admin/machines/delete", () => {
mutationMock.mockReset()
deleteManyMock.mockReset()
assertAuthenticatedSession.mockReset()
mutationMock.mockImplementation(async (_ctx, payload) => {
if (payload && typeof payload === "object" && "machineId" in payload) {
return { ok: true }
}
return { _id: "user_123" }
})
assertAuthenticatedSession.mockResolvedValue({
user: {
email: "admin@example.com",
@ -41,8 +53,7 @@ describe("POST /api/admin/machines/delete", () => {
avatarUrl: null,
},
})
mutationMock.mockResolvedValueOnce({ _id: "user_123" })
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const consoleSpy = vi.spyOn(console, "error").mockImplementation(function noop() {})
restoreConsole = () => consoleSpy.mockRestore()
})
@ -52,7 +63,6 @@ describe("POST /api/admin/machines/delete", () => {
})
it("returns ok when the machine removal succeeds", async () => {
mutationMock.mockResolvedValueOnce({ ok: true })
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {
@ -69,7 +79,12 @@ describe("POST /api/admin/machines/delete", () => {
})
it("still succeeds when the Convex machine is already missing", async () => {
mutationMock.mockRejectedValueOnce(new Error("Máquina não encontrada"))
mutationMock.mockImplementation(async (_ctx, payload) => {
if (payload && typeof payload === "object" && "machineId" in payload) {
throw new Error("Máquina não encontrada")
}
return { _id: "user_123" }
})
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {
@ -84,7 +99,12 @@ describe("POST /api/admin/machines/delete", () => {
})
it("returns an error for other Convex failures", async () => {
mutationMock.mockRejectedValueOnce(new Error("timeout error"))
mutationMock.mockImplementation(async (_ctx, payload) => {
if (payload && typeof payload === "object" && "machineId" in payload) {
throw new Error("timeout error")
}
return { _id: "user_123" }
})
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -0,0 +1,40 @@
import { expect, test } from "vitest"
test("CTA button snapshot", async () => {
const html = `
<main
style="
font-family: 'Inter', sans-serif;
padding: 48px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #111827, #1f2937);
min-height: 320px;
"
>
<button
data-testid="cta"
style="
font-size: 18px;
padding: 14px 28px;
border-radius: 9999px;
border: none;
color: white;
background: #2563eb;
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.35);
cursor: pointer;
"
>
Abrir chamado
</button>
</main>
`
document.body.innerHTML = html
const ctaButton = document.querySelector("[data-testid='cta']")
expect(ctaButton).toBeTruthy()
await expect(document.body).toMatchScreenshot("cta-button")
})

2
tests/setup.browser.ts Normal file
View file

@ -0,0 +1,2 @@
// Browser-specific Vitest bootstrap. Keep this file free of Node globals.
// Add global mocks or polyfills here when we start writing browser tests.

View file

@ -1,22 +1,61 @@
import path from "path"
import { fileURLToPath } from "url"
import { defineConfig } from "vitest/config"
import { playwright } from "@vitest/browser-playwright"
import tsconfigPaths from "vite-tsconfig-paths"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const srcDir = path.resolve(__dirname, "./src")
const convexDir = path.resolve(__dirname, "./convex")
const isCI = process.env.CI === "true"
const isBrowserRun = process.env.VITEST_BROWSER === "true"
export default defineConfig({
root: __dirname,
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@/convex": path.resolve(__dirname, "./convex"),
"@": srcDir,
"@/convex": convexDir,
},
},
test: {
pool: (process.env.VITEST_POOL as "threads" | "forks" | "vmThreads" | undefined) ?? "threads",
environment: "node",
globals: true,
setupFiles: isBrowserRun ? ["./tests/setup.browser.ts"] : ["./vitest.setup.node.ts"],
pool: (process.env.VITEST_POOL as "threads" | "forks" | "vmThreads" | undefined) ?? "threads",
testTimeout: isBrowserRun ? 30000 : 15000,
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}", "convex/**/*.ts"],
exclude: ["**/*.d.ts", "**/*.test.*", "tests/**"],
reportsDirectory: "./coverage",
},
deps: {
registerNodeLoader: true,
},
alias: {
"@": srcDir,
"@/convex": convexDir,
},
browser: {
enabled: isBrowserRun,
provider: playwright({
launchOptions: {
headless: isCI ? true : undefined,
},
}),
instances: [
{
browser: "chromium",
launch: {
headless: isCI ? true : false,
},
},
],
trace: isCI ? "on-first-retry" : "off",
},
environment: "jsdom",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
setupFiles: ["./vitest.setup.ts"],
testTimeout: 15000,
exclude: isBrowserRun ? [] : ["tests/browser/**/*.browser.test.ts"],
},
})

8
vitest.setup.node.ts Normal file
View file

@ -0,0 +1,8 @@
// Node-only Vitest bootstrap: keep browser runs free from process dependencies.
if (typeof process !== "undefined" && process.versions?.node) {
await import("tsconfig-paths/register")
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL
}

View file

@ -1,3 +0,0 @@
process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "test-secret"
process.env.NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL