Files
DeskcommCRM/tests/unit/tailwind-tokens.test.ts
T
Rafael César Rocha MonteiroandClaude Opus 5 72d2ed4c35 build(tailwind): migra para o Tailwind 4 com config em CSS
O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:48:00 -03:00

207 lines
9.4 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Guardas da migração para o Tailwind 4 (config em CSS).
*
* O `tailwind.config.ts` deixou de existir: o que era `theme.extend` virou um
* bloco `@theme inline` dentro de `app/globals.css`. Isso troca um arquivo que
* o TypeScript conferia por um arquivo que ninguém confere — e três coisas
* passam a poder quebrar em silêncio, com build verde e tela errada. Cada uma
* tem um teste aqui.
*/
const RAIZ = process.cwd();
const CSS = fs.readFileSync(path.join(RAIZ, "app/globals.css"), "utf8");
/** Recorta `<seletor> { … }` por casamento de chave, ancorado em início de linha. */
function bloco(seletor: string): string {
const rx = new RegExp(`^${seletor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{`, "m");
const i = CSS.search(rx);
if (i < 0) throw new Error(`não achei o bloco \`${seletor}\` em globals.css`);
const fim = CSS.indexOf("\n}", i);
if (fim < 0) throw new Error(`bloco \`${seletor}\` sem fechamento em globals.css`);
return CSS.slice(i, fim);
}
/** Nomes de custom property declarados dentro de um bloco. */
function propsDe(texto: string): string[] {
return [...texto.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].flatMap((m) => (m[1] ? [m[1]] : []));
}
describe("Tailwind 4 — a ponte token → utilitário", () => {
it("não voltou a existir config em JS nem diretiva `@tailwind`", () => {
// Os dois convivem tecnicamente (`@config` existe no v4), mas conviver é o
// problema: com um `tailwind.config.ts` de volta, metade dos tokens passa a
// vir de um arquivo e metade do outro, e a divergência só aparece na tela.
expect(fs.existsSync(path.join(RAIZ, "tailwind.config.ts"))).toBe(false);
expect(fs.existsSync(path.join(RAIZ, "tailwind.config.js"))).toBe(false);
expect(CSS).not.toMatch(/^@tailwind\s/m);
expect(CSS).toMatch(/^@import "tailwindcss"/m);
});
it("mantém os blocos de token FORA de `@layer` — é o que neutraliza a auto-referência", () => {
// `@theme inline` emite, dentro de `@layer theme`, linhas do tipo
// `--color-bg: var(--color-bg)`, porque os nomes de token do produto já
// ocupam o namespace `--color-*` do Tailwind. Elas são inofensivas por UM
// motivo só: declaração sem layer vence declaração em layer, e o `:root`
// autoral está sem layer. Embrulhar `:root` num `@layer` inverteria a
// precedência, a auto-referência passaria a valer, e TODA cor do produto
// viraria inválida — tela em branco e preto, com build verde.
// O que se procura é o bloco que DECLARA token. `@layer base` legitimamente
// tem um `[data-theme="dark"] { color-scheme: dark }` — regra de tema, não
// definição de token; ela pode morar em layer sem consequência nenhuma.
for (const l of CSS.matchAll(/^@layer\s+([a-z]+)\s*\{/gim)) {
const inicio = l.index ?? 0;
const fim = CSS.indexOf("\n}", inicio);
const corpo = CSS.slice(inicio, fim < 0 ? CSS.length : fim);
const regras = corpo.matchAll(/^\s*(:root|\[data-theme="(?:light|dark)"\])\s*\{/gm);
for (const r of regras) {
const de = r.index ?? 0;
const ate = corpo.indexOf("}", de);
const dentro = corpo.slice(de, ate < 0 ? corpo.length : ate);
expect(
/^\s*--[a-z0-9-]+\s*:/im.test(dentro),
`\`${r[1]}\` declara token dentro de @layer ${l[1]} — isso liga a auto-referência`,
).toBe(false);
}
}
});
it("todo token que o `@theme inline` consome existe no `:root`", () => {
// Um `var(--color-foo)` no @theme apontando para nada não gera erro: o
// utilitário nasce, aplica um valor vazio, e o elemento fica sem cor. Este
// teste é o que transforma o erro de digitação em falha de CI.
const raiz = new Set(propsDe(bloco(":root")));
const tema = bloco("@theme inline");
const consumidos = [...tema.matchAll(/var\((--[a-z0-9-]+)\)/gi)].flatMap((m) =>
m[1] ? [m[1]] : [],
);
expect(consumidos.length).toBeGreaterThan(50);
// As duas fontes são injetadas pelo `next/font` como custom property no
// `<html>` (app/layout.tsx), não pelo `:root` do CSS — por isso não caem na
// regra acima. A isenção não é um buraco: o teste confere logo abaixo que
// elas continuam sendo declaradas lá.
const DE_FORA_DO_CSS = ["--font-atkinson", "--font-mono"];
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
for (const v of DE_FORA_DO_CSS) {
expect(layout, `${v} deixou de ser declarada pelo next/font`).toContain(`"${v}"`);
}
const orfaos = [...new Set(consumidos)].filter(
(v) => !raiz.has(v) && !DE_FORA_DO_CSS.includes(v),
);
expect(orfaos, `tokens referenciados no @theme mas ausentes do :root`).toEqual([]);
});
it("o `@source` cobre toda pasta que realmente escreve className", () => {
// `source(none)` desliga a descoberta automática. O preço é este: pasta de
// UI nova fora da lista perde TODAS as classes, sem erro de build — a tela
// simplesmente renderiza sem estilo.
const declarados = [...CSS.matchAll(/^@source\s+"\.\.\/([a-z-]+)"/gim)].flatMap((m) =>
m[1] ? [m[1]] : [],
);
expect(declarados.length).toBeGreaterThan(0);
const raizesComClasse = fs
.readdirSync(RAIZ, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
.map((d) => d.name)
.filter((nome) => {
// Só interessa pasta que entrega UI ao browser. `tests/` e `docs/`
// escrevem className em fixture e em exemplo, e ficam de fora de
// propósito — varrê-las publicaria CSS que nenhuma tela usa.
if (["tests", "docs", "scripts", "supabase", "public", "tasks", "loop"].includes(nome)) {
return false;
}
return temClassName(path.join(RAIZ, nome));
});
const faltando = raizesComClasse.filter((n) => !declarados.includes(n));
expect(faltando, "pasta com className fora do @source de app/globals.css").toEqual([]);
});
});
describe("Tailwind 4 — utilitários que mudaram de significado", () => {
const ARQUIVOS = listarFontes(["app", "components", "lib", "hooks"]);
it("não usa `rounded` puro — no v4 ele é 0.25rem, não o `--radius-md` do produto", () => {
// No v3 este projeto redefinia o DEFAULT de borderRadius para
// `var(--radius-md)` (8px). O v4 não tem esse DEFAULT sobrescrevível — nem
// por `@utility rounded`, que o embutido vence. Deixar `rounded` puro
// encolhe o raio de 8px para 4px em silêncio.
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`])rounded(?=[\s"'`])/g);
expect(culpados, "use `rounded-md` (8px) ou o grau explícito").toEqual([]);
});
it("não usa `outline-none` — no v4 esse nome virou `outline-hidden`", () => {
// O `outline-none` do v4 é outra coisa (`outline-style: none`) e não
// preserva o contorno transparente que o modo de alto contraste do sistema
// precisa. Trocar por engano degrada acessibilidade sem quebrar nada.
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`:])outline-none(?=[\s"'`])/g);
expect(culpados, "use `outline-hidden`").toEqual([]);
});
it("não usa `flex-shrink-*` / `flex-grow-*` — renomeados para `shrink-*` / `grow-*`", () => {
const culpados = ocorrencias(ARQUIVOS, /(?<=[\s"'`:])flex-(shrink|grow)(-\d+)?(?=[\s"'`])/g);
expect(culpados, "use `shrink-*` / `grow-*`").toEqual([]);
});
});
// ── auxiliares ────────────────────────────────────────────────────────────
function listarFontes(raizes: string[]): string[] {
const saida: string[] = [];
const anda = (dir: string) => {
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, d.name);
if (d.isDirectory()) {
if (d.name === "node_modules" || d.name.startsWith(".")) continue;
anda(p);
} else if (/\.tsx?$/.test(d.name) && !/\.(test|spec)\.tsx?$/.test(d.name)) {
saida.push(p);
}
}
};
for (const r of raizes) {
const p = path.join(RAIZ, r);
if (fs.existsSync(p)) anda(p);
}
return saida;
}
function ocorrencias(arquivos: string[], rx: RegExp): string[] {
const achados: string[] = [];
for (const f of arquivos) {
const linhas = fs.readFileSync(f, "utf8").split("\n");
linhas.forEach((linha, i) => {
// Prosa não é classe. `lib/ai/cost.ts` documenta "rounded up" e viraria
// culpado; a heurística de linha de comentário é grosseira mas suficiente,
// porque nenhuma classe do produto mora em linha iniciada por `*` ou `//`.
const cru = linha.trimStart();
if (cru.startsWith("*") || cru.startsWith("//") || cru.startsWith("/*")) return;
if (new RegExp(rx.source, rx.flags.replace("g", "")).test(linha)) {
achados.push(`${path.relative(RAIZ, f)}:${i + 1}`);
}
});
}
return achados;
}
function temClassName(dir: string): boolean {
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
if (d.name === "node_modules" || d.name.startsWith(".")) continue;
const p = path.join(dir, d.name);
if (d.isDirectory()) {
if (temClassName(p)) return true;
} else if (/\.tsx$/.test(d.name) && fs.readFileSync(p, "utf8").includes("className=")) {
return true;
}
}
return false;
}