Qualidade e Processo
Convenções, pré-commit, build/deploy, harness e Definition of Done.
- 13 — Convenções
- 14 — Qualidade e pré-commit
- 15 — Build e deploy
- 16 — Template de `CLAUDE.md`
- 17 — Primeira tela
- 18 — Harness
- 19 — `init.sh` e Definition of Done
- 20 — Ciclo de desenvolvimento
- 22 — Testes E2E com Playwright
13 — Convenções
13 — Convenções
Nomenclatura de arquivos, dados, hooks, handlers e constantes. A regra que governa tudo: dado do backend mantém o nome do backend; lógica do front é em inglês. Ao final, uma lista de anti-padrões reais e recorrentes que não devem ser replicados.
Idioma
| Elemento | Idioma | Exemplo |
|---|---|---|
| Propriedade vinda do backend | Português |
_Nome, _DataExpiracao, Documentos
|
| Interface que espelha o backend | Inglês |
SupplierXML, SaveShipmentRequest
|
| Variável local, função, helper | Inglês |
isLoading, groupByLayout, handleClick
|
| Nome de componente | Inglês |
DataTable, LoadingButton
|
| Nome de página | Inglês | IncludeSupplierPage |
| Texto de interface | Português | label="Nome do fornecedor" |
| Mensagem de validação | Português | "Informe o CNPJ" |
| Comentário de código | Português | ver estilo |
| Segmento de URL | Inglês | registration/supplier-type/add |
O critério: se o nome atravessa a fronteira com o backend, ele é do backend. Se vive só no front, é inglês.
Propriedades do backend: _Prefixo
interface FornecedorXML {
_OID: string; // primitivo → underscore
_Nome: string; // primitivo → underscore
_Ativo: boolean; // primitivo → underscore
_DataCadastro: string; // primitivo → underscore
Endereco: EnderecoXML; // objeto → sem prefixo
Documentos: DocumentoXML[]; // coleção → sem prefixo
}
Regra do Curio: primitivo leva _, objeto complexo não leva.
Isso não é decoração — é como o payload chega. Renomear para camelCase exigiria uma camada de
mapeamento em toda request e resposta. O projeto opta por não ter essa camada: o custo é conviver com
_Nome no front.
Consequências:
- Campos de formulário usam os mesmos nomes (
name="_Nome"), evitando conversão no submit. - Sufixo
XMLnas interfaces que espelham a estrutura do backend, distinguindo-as dos tipos do front.
Booleano do backend: Flag
O Curio não tem tipo booleano nativo trafegando na rede — o valor real que chega/sai é a string "S"
ou "N". Nunca converta para boolean na borda; mantenha Flag do início ao fim.
type Flag = "S" | "N";
interface FornecedorXML {
_Ativo: Flag; // não `boolean`
}
Regra: true → "S", false → "N", em qualquer campo que atravesse a fronteira com o backend
(request ou response). Se o front precisar de um boolean de verdade (ex.: checked de um Checkbox),
converta na borda da UI, nunca no tipo que representa o payload:
<Checkbox checked={fornecedor._Ativo === "S"} onChange={(e) => setValue("_Ativo", e.target.checked ? "S" : "N")} />
Arquivos e pastas
| Item | Padrão | Exemplo |
|---|---|---|
| Pasta de componente | PascalCase |
FormField/ |
| Componente | PascalCase.tsx |
FormField.tsx |
| Estilos | PascalCase.styles.ts |
FormField.styles.ts |
| Barrel | index.ts |
— |
| Hook | camelCase.ts |
useCurioMutation.ts |
| Utilitário | camelCase.ts |
formatters.ts |
| Tipos de uma tela | interfaces.ts |
service/interfaces.ts |
| Hooks de uma tela | hooks.ts |
service/hooks.ts |
| Constantes de RM/UC | constants.ts |
service/constants.ts |
| Schemas de uma tela | schemas.ts |
— |
| Página | PascalCasePage.tsx |
IncluirFornecedorPage.tsx |
Páginas levam sufixo Page; componentes não. Deixa óbvio, no import, o que é rota e o que é peça.
Hooks
| Tipo | Padrão | Exemplo |
|---|---|---|
| Query de coleção | use{Entidade}s |
useFornecedores |
| Query de item | use{Entidade} |
useFornecedor |
| Mutation de caso de uso | use{Verbo}{Entidade} |
useSalvaFornecedor |
| Mutation genérica | use{Verbo}{Entidade}Mutation |
useCreateFornecedorMutation |
| Utilitário | use{Descritor} |
useDebounce, useModal
|
Hooks de caso de uso usam verbo em português, espelhando o request do backend:
RM_INCLUI_OBJETO → useIncluiFornecedor
RM_SALVA_OBJETO → useSalvaFornecedor
RM_OBTEM_DOCUMENTOS → useObtemDocumentos
Isso torna rastreável qual hook corresponde a qual request sem abrir o arquivo.
Handlers e callbacks
interface Props {
onSubmit: (data: FormData) => void; // prop → on{Ação}
onCancel: () => void;
}
const Componente = ({ onSubmit }: Props) => {
const handleFormSubmit = (data: FormData) => {
// interno → handle{Ação}
onSubmit(data);
};
};
on* é o que o componente recebe. handle* é o que ele define. Nunca inverta — a distinção
diz, na leitura, de onde vem o comportamento.
Constantes
// Módulo, imutável, conhecido em tempo de escrita → SCREAMING_SNAKE_CASE
const FORNECEDOR_RMS = { USE_CASE: "4821", SALVAR: "RM_SALVAR_DADOS_FORNECEDOR" };
export const STORAGEKEY = "br.com.nomedoprojeto";
// Objeto de configuração / default → camelCase
const defaultFormValues = { _Nome: "", _Ativo: true };
const fornecedorKeys = { all: ["fornecedores"] as const };
Ids de caso de uso e nomes de request sempre dentro de um objeto _RMS — ver
06 — nunca literal no JSX.
Tipos e interfaces
-
interfacepara formato de objeto;typepara união, interseção e utilitário. - Interface de props do componente exportada e nomeada
{Componente}Props. - Tipo de formulário inferido do zod (
z.infer), nunca escrito à mão — ver 11. - Sufixo
XMLpara interfaces que espelham o backend. - Sufixos
Request/Responsepara payloads de caso de uso.
Comentários
Comentário em código segue estilo ultra-comprimido (telegráfico), não prosa:
// ERRADO
// This function is responsible for grouping the routes by their layout so that
// we can render one parent Route per layout.
// CERTO
// agrupa por layout pra montar uma <Route> pai por layout
Comente por quê, não o quê. O código já diz o quê.
Quando um eslint-disable for inevitável, justifique na mesma linha:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @curio nao tipa o service
constructor(service: any, driver: RequestDriver) {
-- seguido do motivo é obrigatório. eslint-disable sem justificativa não passa em revisão.
Imports
Ordem, com linha em branco entre grupos:
// 1. React e libs externas
import React, { useEffect, useState } from "react";
import { Box, Button } from "@mui/material";
// 2. Aliases internos
import { useAuth } from "@context/AuthProvider";
import { FormField } from "@components/common";
// 3. Relativos da própria feature
import { useSalvaFornecedor } from "./service/hooks";
import { containerStyles } from "./IncluirFornecedorPage.styles";
Use aliases para cruzar pastas (@components/...), relativos dentro da própria feature (./service/...).
Nunca ../../../hooks/useX — para isso existe @hooks.
Anti-padrões recorrentes
Implementações reais tendem a acumular as mesmas inconsistências. A coluna "Padrão correto" é o que o projeto novo adota sempre — nunca replique a coluna da esquerda.
| Anti-padrão | Padrão correto |
|---|---|
Duas pastas para o mesmo domínio em idiomas diferentes (Documentos/ e Documents/) |
Um idioma só para nomes de pasta de página. Adote português |
Constantes de rota misturando idiomas (PATHS.DOCUMENTS.* ao lado de PATHS.DOCUMENTOS.*) |
Um idioma só nas URLs. Adote português |
CLAUDE.md/guia descreve uma pasta de requests que não existe mais no código |
Requests por tela em pages/<Tela>/service/ — 03
|
Documentação diz uma porta; vite.config.ts usa outra |
Documente a porta real, confira contra o código |
Documentação cita uma STORAGEKEY de exemplo; o código usa outra |
Chave própria do projeto, documentada corretamente |
Documentação diz sessionStorage; o código de logout limpa localStorage (ou vice-versa) |
Um storage só, consistente — 05 |
Logout faz localStorage.clear() (apaga tudo, não só a sessão) |
Remover só a chave da sessão |
Barrel (index.ts) desatualizado, não exporta componente/módulo já existente |
Barrel atualizado no mesmo commit do componente |
Campo de config tipado como number, mas o JSON de runtime entrega string
|
Tipar como string — 04
|
Rota como string literal ("dashboard") em vez da constante (PATHS.DASHBOARD) |
Sempre a constante |
Código de debug (<Profiler>, console.log) atrás de uma env var que nunca é definida |
Remover código morto; sem eslint-disable decorativo |
| Script de setup de ambiente loga o valor de um token/segredo | Nunca logar segredo |
| Arquivo de config de ambiente com token real commitado | Placeholder no repo; valor via .env — 04
|
| Detecção de expiração de sessão comparando string de mensagem de erro | Sinalização por código de erro em isAuthError
|
login()/connect() retornam o erro em vez de lançar |
Deixar lançar e tratar no hook |
Estilos num objeto styles = { root, drawer, ... } por arquivo |
Constantes nomeadas exportadas individualmente — ver 12 |
Se você encontrar mais algum ao portar código, acrescente aqui em vez de resolver em silêncio.
14 — Qualidade e pré-commit
14 — Qualidade e pré-commit
Três gates:
vite-plugin-checkerdurante o dev,pre-commitno commit,npm run buildno build. Não há suíte de testes — assumido e declarado, não esquecido. Regra: o gate corrige o que dá para corrigir e barra o que não dá.
Os gates
| Quando | O que roda | Barra? |
|---|---|---|
npm run dev |
vite-plugin-checker (type-check contínuo) |
Não — mostra overlay |
git commit |
Node version, flags de debug, lint-staged
|
Sim |
git pull |
post-merge → npm install se preciso |
Não |
npm run build |
lint → tsc → vite build
|
Sim |
Ausência de testes — declarado
Este padrão não tem suíte de testes automatizados. Não há Vitest, Jest, Testing Library nem Playwright. Isso não é omissão da documentação: é o estado da referência.
O que substitui:
-
strict: true+noUnusedLocals+noUnusedParametersno TypeScript - ESLint com
--max-warnings 0 -
npm run buildcomo verificação de integração (compila tudo) - Verificação manual da tela contra o backend real
Se o projeto novo quiser testes, decida no início. Adicionar depois exige refatorar componentes que
nasceram acoplados a Context e ao UseCaseManager. Se adotar, atualize 19 para
incluir o comando na Definition of Done.
ESLint
Flat config (eslint.config.js) — ESLint 9+ abandonou o formato .eslintrc.json. Não é uma
escolha do guia, é obrigatório a partir da v9; --ext ts,tsx também deixou de ser aceito no CLI (o
escopo de arquivos agora vem do files de cada bloco de config).
// eslint.config.js
import js from "@eslint/js";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";
import globals from "globals";
export default [
{ ignores: ["dist", "build", "vite.config.ts", "setEnvironment.js"] },
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
globals: { ...globals.browser, ...globals.node, ...globals.es2020 }
},
plugins: { "@typescript-eslint": tsPlugin, "react-hooks": reactHooks },
rules: {
...tsPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-unused-vars": "off",
"no-undef": "off",
"@typescript-eslint/no-unused-vars": "error",
"react-hooks/exhaustive-deps": "warn",
"no-debugger": "error",
"no-console": ["error", { allow: ["warn", "error", "info"] }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-empty-interface": "error",
"no-shadow": "off",
"@typescript-eslint/no-shadow": "error",
"no-throw-literal": "error",
"no-return-await": "error",
"no-duplicate-imports": "error",
eqeqeq: ["error", "always"],
"no-var": "error",
"prefer-const": "error",
"use-isnan": "error"
}
},
prettier
];
Requer @eslint/js e globals como devDependencies novas (não existiam no formato antigo).
prettier (de eslint-config-prettier) por último desliga as regras de formatação do ESLint —
Prettier manda no formato, ESLint manda na correção.
no-undef: "off"não é opcional. O@typescript-eslint/recommendeddo formato antigo (.eslintrc.json) desligavano-undefinternamente ao resolver oextends; pegando o objeto de regras direto (tsPlugin.configs.recommended.rules) em flat config, esse desligamento não vem junto — sem repetir explicitamente,no-undefacusa falso positivo em tipos ambient do TS (EventListener,Reactusado só como tipo em.ts). Otscjá cobre isso; deixe o ESLint fora do caminho.
Regras que merecem explicação
| Regra | Efeito prático |
|---|---|
no-console (permite warn/error/info) |
console.log esquecido barra o commit |
@typescript-eslint/no-explicit-any |
any exige eslint-disable justificado |
react-hooks/exhaustive-deps: warn |
Ver abaixo |
eqeqeq |
== proibido |
no-shadow |
Variável interna não pode mascarar externa |
exhaustive-depscomo"warn", não"off". OsuseEffectde abertura de caso de uso (06) dependem de rodar em condições específicas, e a regra reclamaria de arrays intencionalmente incompletos — resolva com// eslint-disable-next-linejustificado nesses poucos casos, não desligando a regra inteira.
eslint-plugin-react-hooks v7 trouxe regras novas — decida o alcance
O recommended da v7 é bem mais amplo que o da v4 (que só tinha rules-of-hooks +
exhaustive-deps). Ele inclui regras no estilo "React Compiler" — por exemplo
react-hooks/set-state-in-effect, que acusa qualquer setStateX(...) chamado direto no corpo de um
useEffect (fora de um callback de evento/subscrição).
Isso pega padrões legítimos de código já existente: estado 100% derivado de outro estado/prop que
era sincronizado via useEffect. A correção correta não é suprimir a regra, é aplicar o padrão que
o React recomienda:
- Se o valor é derivado de outro estado/prop (ex.: uma lista filtrada de uma query), calcule direto
no corpo do componente — não precisa de
useState/useEffectnenhum. - Se precisa resincronizar quando uma prop muda (ex.: um hook que lê de
localStorageporkey), ajuste o estado durante o render (guardando a prop anterior em outrouseStatee comparando), não dentro de umuseEffect.
Adotar o recommended completo é a recomendação — ele pega bug real de cascata de render. Se o
projeto novo preferir adiar, ligue só rules-of-hooks + exhaustive-deps (equivalente ao v4) e trate o
resto depois, mas isso é uma escolha explícita, não o padrão.
--max-warnings 0
eslint . --report-unused-disable-directives --max-warnings 0
Warning é erro. Sem isso, warnings acumulam até ninguém mais ler a saída.
--report-unused-disable-directives acusa eslint-disable que não suprime mais nada — remove
supressão obsoleta. Sem --ext: em flat config o CLI não aceita mais essa flag, o escopo de arquivos
vem do files de cada bloco em eslint.config.js.
Prettier
// .prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 120,
"arrowParens": "always",
"endOfLine": "crlf",
"bracketSpacing": true,
"bracketSameLine": true,
"proseWrap": "preserve"
}
# .prettierignore
node_modules
dist
build
coverage
# Gerado em runtime por setEnvironment.js
public/config.json
package-lock.json
Dois pontos não-óbvios:
-
endOfLine: "crlf"— o time é Windows. Se houver dev em Linux/macOS, troque para"auto"e configure.gitattributes, senão todo commit reescreve o arquivo inteiro. -
bracketSameLine: true— o>de tag multi-linha fica na última prop, não em linha própria. Incomum, mas é o padrão da base.
husky
Instalação
// package.json — repositório de módulo único
"scripts": { "prepare": "husky" }
// package.json — projeto web dentro de um monorepo
"scripts": { "prepare": "cd ../.. && husky caminho/do/modulo/.husky" }
A forma do monorepo instala os hooks apontando para .husky/ dentro do módulo web, enquanto o
core.hooksPath é configurado na raiz do repositório.
Armadilha do monorepo.
core.hooksPathé config local do clone, mas.husky/é versionado. Trocar de branch pode ligar ou desligar os hooks em silêncio. Depois de clonar ou de trocar para uma branch que mexe empackage.json, rodenpm run preparee confirme comgit config core.hooksPath.
pre-commit
# .husky/pre-commit
# git roda o hook da raiz do repo; projeto Node pode estar em subpasta
hookdir="$(cd "$(dirname -- "$0")" && pwd)"
cd "$hookdir/.." || exit 1
# valida Node sem depender de fnm no shell
. "$hookdir/check-node-version.sh"
. "$hookdir/check-debug-flags.sh"
npx lint-staged
Três verificações, nesta ordem: versão de Node → flags de debug → lint-staged.
check-node-version.sh
# .husky/check-node-version.sh
# Sourced pelo hook, ja com cwd no projeto Node. Nao depende de fnm/nvm
# estarem carregados (GUIs de git nao herdam o profile) -- compara o node
# do PATH com .node-version (aceita >=).
expected_version=$(tr -d '[:space:]' < .node-version 2>/dev/null)
actual_version=$(node -v 2>/dev/null | sed 's/^v//')
if [ -z "$actual_version" ]; then
echo "hook: 'node' nao encontrado no PATH. Ative o Node >= ${expected_version:-do projeto} (ex: 'fnm use')."
exit 1
fi
if [ -n "$expected_version" ]; then
lower_version=$(printf '%s\n%s\n' "$expected_version" "$actual_version" | sort -V | head -n1)
if [ "$lower_version" != "$expected_version" ]; then
echo "hook: Node v$actual_version encontrado, necessario >= v$expected_version (.node-version)."
exit 1
fi
fi
Resolve um problema real: commitar por GUI de Git com Node 8 ativo faz o lint-staged falhar com erro
incompreensível.
check-debug-flags.sh
# .husky/check-debug-flags.sh
# Bloqueia commit com enableLogs: true (flag de depuracao de useUseCaseControls).
#
# So chama 'exit' no caminho de erro -- este arquivo e' sourced, entao
# 'exit 0' encerraria o hook antes do lint-staged rodar.
repo_root=$(git rev-parse --show-toplevel)
staged_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx')
if [ -n "$staged_files" ]; then
matches=""
for file in $staged_files; do
file_match=$(grep -n "enableLogs:[[:space:]]*true" "$repo_root/$file" 2>/dev/null || true)
if [ -n "$file_match" ]; then
matches="$matches$file:
$file_match
"
fi
done
if [ -n "$matches" ]; then
echo "hook: 'enableLogs: true' encontrado em arquivo(s) staged - remova antes de commitar:" >&2
printf '%s' "$matches" >&2
exit 1
fi
fi
O comentário sobre exit 0 não é decorativo. O arquivo é carregado com . (source). Um exit 0
no fim encerraria o pre-commit inteiro antes do lint-staged, e o commit passaria sem lint. Ao
escrever um hook novo neste estilo, só chame exit no caminho de erro.
Adapte a lista de flags ao projeto: qualquer flag de depuração que não deva ser commitada entra aqui.
post-merge
# .husky/post-merge
# Instala dependencias quando o merge/pull alterou package.json/lock.
changed=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
case "$changed" in
*caminho/do/modulo/package-lock.json*|*caminho/do/modulo/package.json*)
echo "post-merge: dependencias mudaram -> rodando npm install..."
hookdir="$(cd "$(dirname -- "$0")" && pwd)"
cd "$hookdir/.." || exit 1
. "$hookdir/check-node-version.sh"
npm install
;;
esac
Elimina a classe de bug "puxei a branch e o app não sobe". Ajuste os caminhos do case ao layout do
projeto novo.
lint-staged
// package.json
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"prettier --write ."
],
"**/*.{json,css,scss,md,html,yml,yaml}": ["prettier --write ."]
}
Duas imprecisões comuns nesta configuração, que valem corrigir.
- Os comandos usam
.(projeto inteiro) em vez dos arquivos staged. Olint-stagedpassa a lista de arquivos como argumento, que aqui é ignorada. Funciona, mas fica lento e formata arquivos que você não tocou.prettier --write .faz o hook reescrever e re-stagear arquivos. Um commit pode sair com formatação que você não fez.Forma correta:
"lint-staged": { "src/**/*.{ts,tsx}": [ "eslint --fix --report-unused-disable-directives --max-warnings 0", "prettier --write" ], "**/*.{json,css,scss,md,html,yml,yaml}": ["prettier --write"] }Sem
., olint-stagedanexa só os arquivos staged.
vite-plugin-checker
checker({
typescript: true,
overlay: { initialIsOpen: false, position: "tl" },
terminal: true
});
Type-check contínuo durante npm run dev, em overlay e no terminal. Não substitui tsc no build —
o npm run build roda tsc separadamente, porque o checker só verifica o que foi tocado na sessão.
Contornar os gates
git commit --no-verify pula os hooks. Reserve para emergência real (commit de WIP em branch pessoal).
Nunca em branch compartilhada.
HUSKY=0 desabilita os hooks no ambiente — útil em CI, onde o pipeline já roda lint e build.
15 — Build e deploy
15 — Build e deploy
Builds por ambiente, o artefato gerado e como ele é servido atrás do ISAPI. A escolha de ambiente acontece em build (qual
config.jsoné embarcado) e pode ser trocada em deploy (substituindo o arquivo) — sem recompilar. Não há pipeline de CI na referência. Isso é lacuna, não decisão.
Scripts
| Comando | O que faz |
|---|---|
npm run dev |
env:dev + Vite dev server (porta 5000) |
npm run start:homolog |
env:homolog + dev server apontando para homologação |
npm run build |
lint → env:prod → tsc → vite build
|
npm run build:homolog |
env:homolog → tsc → vite build
|
npm run preview |
Serve o dist/ localmente |
O build de produção inclui o lint
"build": "npm run lint && npm run env:prod && tsc && vite build"
Quatro etapas, sequenciais, qualquer falha aborta:
-
lint— ESLint com zero warnings -
env:prod— gerapublic/config.jsona partir deconfig/prod.json -
tsc— type-check completo (noEmit) -
vite build— bundle emdist/
build:homolognão roda o lint. É inconsistente com obuild. No projeto novo, inclua o lint nos dois:"build:homolog": "npm run lint && npm run env:homolog && tsc && vite build".
Por que tsc além do vite-plugin-checker
O checker do dev só verifica o que foi tocado na sessão. tsc verifica o projeto inteiro. Um erro de
tipo num arquivo que ninguém abriu passa pelo checker e é pego aqui.
O artefato
dist/
├── index.html
├── config.json ← copiado de public/, define o backend
└── assets/
├── index-<hash>.js
├── index-<hash>.css
└── <Pagina>-<hash>.js ← um chunk por página (lazy)
build: { outDir: "dist", sourcemap: true }
sourcemap: true publica os .map junto do bundle — qualquer um com acesso à URL lê o código-fonte
original. Aceitável em sistema interno; desligue se o app for exposto na internet.
Trocar de ambiente sem rebuild
Como o config.json é lido em runtime (fetch("./config.json") — ver 04),
o mesmo dist/ serve qualquer ambiente:
# aponta um build existente para outro backend
cp config/homolog.json dist/config.json
Isso viabiliza promover exatamente o artefato testado em homologação para produção, sem recompilar.
Consequência: o config.json do build não é a verdade final. Confirme o que está no servidor,
não o que foi buildado.
Deploy ISAPI
O backend Curio é servido por cxIsapiClient.dll sob IIS. O front é um SPA estático publicado no
mesmo IIS, normalmente num diretório virtual ao lado do gateway.
Passos:
-
npm run build(oubuild:homolog) - Copiar o conteúdo de
dist/para o diretório publicado no IIS - Ajustar
dist/config.jsonse o destino diferir do ambiente do build - Validar: abrir a aplicação, fazer login, executar uma tela que chame o backend
Rewrite para SPA
O React Router usa BrowserRouter (history API). Uma URL profunda como
/cadastro/fornecedor/incluir chega ao IIS como um caminho que não existe em disco — e retorna 404.
O IIS precisa de uma regra de rewrite que devolva index.html para qualquer caminho que não seja
arquivo real:
<!-- web.config no diretório publicado -->
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="SPA fallback" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
Sintoma de rewrite ausente: a aplicação funciona navegando pelo menu, mas F5 numa tela interna dá 404.
É comum uma implementação de referência não versionar um
web.config. Se o projeto novo for publicado em IIS, versione-o junto do código — configuração de servidor não deve viver só na memória de quem publica.
base do Vite
Se a aplicação for servida em subcaminho (https://servidor/sistema/ em vez da raiz), configure:
// vite.config.ts
export default defineConfig({ base: "/sistema/" });
Sem isso, os assets/ são requisitados da raiz e não carregam. A referência assume publicação na raiz
(base não é definido).
CI/CD — o que existe e o que falta
É comum encontrar .gitlab/ na raiz só com templates de issue e merge request:
.gitlab/
├── issue_templates/default.md
└── merge_request_templates/default.md
Sem .gitlab-ci.yml, não há pipeline. Se o template de MR pede "Pipeline passando" e "Testes
escritos ou atualizados" sem que o projeto tenha como cumprir nenhum dos dois, é checklist
aspiracional — alinhe o texto ao que existe de verdade.
Recomendação para o projeto novo
Um pipeline mínimo cobre o que hoje depende de disciplina individual:
# .gitlab-ci.yml
image: node:22
stages: [verify, build]
cache:
key: "$CI_COMMIT_REF_SLUG"
paths: [node_modules/]
verify:
stage: verify
script:
- npm ci
- npm run lint
- npx tsc --noEmit
- npm run format:check
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
Ajuste os caminhos se o projeto web for submódulo de um monorepo (cd caminho/do/modulo antes dos comandos).
Se adotar CI, alinhe o checklist do template de MR ao que o pipeline realmente verifica. Checklist que pede o que não existe treina o time a marcar caixas sem ler.
Antes de publicar
16 — Template de `CLAUDE.md`
16 — Template de CLAUDE.md
Arquivo pronto para colar na raiz do projeto novo. Substitua tudo entre
{{ }}e apague o que não se aplicar. Inclui a seção de harness — ver 18 e 19.
Como manter
O CLAUDE.md é carregado automaticamente em toda sessão. Ele é índice, não enciclopédia: aponta
para os guias, não os duplica. Duplicar cria duas versões da verdade — é assim que um CLAUDE.md
diverge do código com o tempo (ver anti-padrões em
13).
Regra: se um fato pode mudar sem que ninguém lembre de atualizar o CLAUDE.md, ele não deveria estar
aqui — deveria estar num guia, referenciado daqui.
Template
# CLAUDE.md
Guia para o Claude Code (claude.ai/code) trabalhar neste repositório.
Projeto: **{{NOME_DO_PROJETO}}** — SPA React que fala com backend Curio via `@curio/client`.
Stack, arquitetura e convenções detalhadas vivem em `harness/guides/` — ver tabela abaixo.
---
## Comandos
```bash
npm run dev # ambiente dev + Vite (porta {{PORTA}})
npm run start:homolog # ambiente de homologação
npm run build # lint + tsc + build de produção
npm run lint # ESLint, zero warnings
npm run format # Prettier
npx tsc --noEmit # type-check completo
```
Node >= {{VERSAO_NODE}} (fixado em `.node-version`). Ative antes de qualquer comando — o `pre-commit`
valida e barra o commit se estiver errado.
**Não há suíte de testes.** A verificação é `lint` + `tsc` + `build` + teste manual da tela contra o
backend real. Não afirme que algo "passou nos testes".
---
## Guide Files
Toda a documentação de harness vive em `harness/`. `CLAUDE.md` e `init.sh` ficam na raiz — são os
pontos de entrada.
```
harness/
├── guides/ — referência técnica — versionado
├── _examples/
│ ├── state/ — feature_list.example.json, progress.example.md, session-handoff.example.md
│ ├── handoffs/ — handoff.example.md
│ └── user_preferences.example.md
├── <branch>/ — tudo que a branch produziu — VERSIONADO
│ ├── state/ — feature_list.json, progress.md, session-handoff.md
│ ├── plans/ — planos de implementação
│ ├── specs/ — specs de design
│ └── handoffs/ — handoffs de sessão
└── user_preferences.md — preferências do dev — ÚNICO ARQUIVO LOCAL (gitignored)
```
`<branch>` é o nome literal da branch atual (`git rev-parse --abbrev-ref HEAD`). **Só
`harness/user_preferences.md` é local.** Tudo dentro de `harness/<branch>/` é versionado — se
`harness/<branch>/state/` não existir ainda, **crie a pasta a partir de `_examples/`** (mecânico); não
invente conteúdo de `feature_list.json`/`progress.md`.
### Guias técnicos
- `harness/guides/commands.md` — build, run, ambientes, variáveis
- `harness/guides/architecture.md` — camadas do front, fluxo até o backend, autenticação
- `harness/guides/curio-framework-guide.md` — sessão, caso de uso, `useCurioMutation`, React Query
- `harness/guides/dev-environment.md` — Node/fnm, `.env`, acesso aos servidores
- `harness/guides/husky-git-hooks.md` — o que cada hook faz e como configurar
- `harness/guides/gitlab-access.md` — autenticação com a API do GitLab {{REMOVER_SE_NAO_USA}}
### Estado de features
- `harness/<branch>/plans/*.md` — planos task-by-task por feature
- `harness/<branch>/specs/*.md` — specs de design
- `harness/<branch>/handoffs/*.md` — handoffs por feature (versionado)
Quando existir plano/spec da feature ativa, ele tem precedência sobre o `feature_list.json` genérico
para o detalhe fino — mas atualize os dois ao final da sessão. Um plano/spec fica na branch onde
nasceu — não realoque se o trabalho continuar em outra branch depois.
### Sem histórico compartilhado entre branches
Não existe mais `global_feature_list.json`. Cada branch é autocontida — decisão deliberada em troca de
simplicidade (ver [18](18-HARNESS.md#reestruturação-2026-08-25-por-que-branch-primeiro)). Não recrie
esse arquivo.
---
## Harness — Estado, Verificação e Módulos
### Startup Workflow
Antes de escrever código:
1. Confirme o diretório com `pwd`.
2. Resolva a branch atual (`git rev-parse --abbrev-ref HEAD`). Se `harness/<branch>/state/` não
existir, crie a partir de `harness/_examples/` (scaffolding — não preencha conteúdo).
3. Leia `harness/user_preferences.md` (se existir).
4. Leia `harness/<branch>/state/feature_list.json` e `harness/<branch>/state/progress.md` (se existirem).
5. Revise commits recentes: `git log --oneline -5`.
6. Rode `./init.sh` (FAST por padrão; `FULL=1 ./init.sh` para a suíte completa).
Se o baseline já estiver quebrado, corrija antes de implementar feature nova.
### Interação com o usuário
Ao levantar decisões ou pontos em aberto, **faça uma pergunta por vez** e espere a resposta antes da
próxima. Não empacote várias perguntas numa lista só.
### Convenções de código
Comentários em código seguem estilo ultra-comprimido (telegráfico), não prosa. Comente **por quê**,
não **o quê**. Ver `harness/guides/` e a seção de convenções da documentação do projeto.
### Módulos (fonte de verdade)
| Módulo | Path | Doc | Verificação |
| ---------------- | ---------- | ----------------------------------------- | ---------------------------------------------------- |
| Frontend (React) | `{{PATH}}` | `harness/guides/curio-framework-guide.md` | `npm run lint` (rápido) / `npm run build` (completo) |
| Configuração | `config/` | `harness/guides/commands.md` | `npm run env:dev` gera `public/config.json` |
Antes de editar um módulo, leia o guia correspondente.
**Nunca editar à mão:** `public/config.json` — é gerado por `setEnvironment.js` a partir de
`config/{env}.json`. Toda mudança de configuração vai no arquivo de origem.
### Definition of Done
Uma tarefa só está concluída quando:
- o comportamento alvo foi implementado;
- `./init.sh` passa (ao menos FAST; FULL antes de considerar pronto para revisão);
- a tela foi **aberta no browser** e o fluxo verificado contra o backend real — sem suíte de testes,
esta é a única evidência funcional que existe;
- `harness/<branch>/state/feature_list.json` foi atualizado (`status` + `evidence`);
- `harness/<branch>/state/progress.md` foi atualizado ao fim da sessão.
Não marque como `passing` só porque o código foi escrito.
### Fim de sessão
1. Atualizar `harness/<branch>/state/feature_list.json` (status + evidência); se algo chegou a
`passing`, mover de `features` para `completed` no mesmo arquivo.
2. Atualizar `harness/<branch>/state/progress.md`.
3. Atualizar `harness/<branch>/state/session-handoff.md` com o resultado da verificação.
4. Commit descritivo com o repo em estado seguro — inclui `state/`, `plans/`, `specs/` e `handoffs/`
da branch, tudo versionado.
5. A próxima sessão deve conseguir rodar `./init.sh` imediatamente.
---
## Arquitetura — resumo
```
Componente → hook de caso de uso → useCurioMutation → UseCaseManager → @curio/client → Backend
```
- **Não é REST.** Casos de uso por id numérico, requests nomeados (`RM_SALVA_OBJETO`).
- **Erro é global.** `queryClient` captura e notifica; páginas não fazem `try/catch` para exibir erro.
- **Config em runtime.** `public/config.json` é lido por `fetch`, não embutido no bundle.
- **Propriedades do backend:** `_Prefixo` para primitivos, sem prefixo para objetos; nomes em português.
Detalhe completo em `harness/guides/curio-framework-guide.md`.
---
## Aliases
| Alias | Path |
| ------------- | ----------------- |
| `@` | `src/` |
| `@components` | `src/components/` |
| `@pages` | `src/pages/` |
| `@hooks` | `src/hooks/` |
| `@utils` | `src/utils/` |
| `@types` | `src/types/` |
| `@api` | `src/api/` |
| `@context` | `src/context/` |
| `@theme` | `src/theme/` |
Todo alias novo entra em `vite.config.ts` **e** `tsconfig.json`.
Se o projeto já tem um CLAUDE.md
Insira a seção de harness nele, não sobrescreva. Leia o arquivo inteiro antes, para não duplicar uma seção "Harness" criada numa tentativa anterior.
17 — Primeira tela
17 — Primeira tela
Receita end-to-end: uma tela de busca + cadastro de
Fornecedor, tocando todas as camadas. Copie, troqueFornecedorpela sua entidade e o id do caso de uso. Ao final há um checklist de verificação — a tela só está pronta quando ele passa.
O que vamos construir
Uma tela que:
- Abre um caso de uso no backend
- Busca fornecedores por filtro
- Exibe o resultado em tabela
- Salva um fornecedor novo
Camadas tocadas: constants → interfaces → hooks → schemas → página → paths → routes →
menuTree.
Pré-requisitos
Do backend, você precisa saber:
-
Id do caso de uso (ex.:
"4821") -
Nomes dos requests (ex.:
RM_INCLUI_OBJETO,RM_BUSCA_FORNECEDORES,RM_SALVA_OBJETO) - Formato dos payloads
Não invente. Pergunte a quem implementou o caso de uso ou descubra com enableLogs
(06).
Sem esse contrato ainda? Construa com placeholders
Se o backend real não existe ou ainda não foi definido, não pule esta receita — construa-a mesmo
assim, com todo id de caso de uso e nome de RM como placeholder explícito
("SUBSTITUA_USE_CASE_ID", "RM_SUBSTITUA_SALVAR"), numa entidade que não possa ser confundida com
domínio real (Exemplo, não Fornecedor). Isso é o passo 11 do bootstrap (02) —
prova, com tsc/lint/build reais, que a cadeia inteira compila e roteia antes de qualquer feature
de negócio existir, e dá ao time algo executável para copiar. Comente no topo do arquivo que é
placeholder, registre a rota normalmente, e apague quando a primeira tela de negócio nascer.
1. Constantes
// src/pages/Cadastro/Fornecedor/service/constants.ts
export const FORNECEDOR_RMS = {
USE_CASE: "4821",
OBTEM_DADOS: "RM_INCLUI_OBJETO",
BUSCAR: "RM_BUSCA_FORNECEDORES",
SALVAR: "RM_SALVA_OBJETO"
};
Ver 06. Nenhum id ou nome de request literal fora deste arquivo.
2. Interfaces
// src/pages/Cadastro/Fornecedor/service/interfaces.ts
export interface FornecedorXML {
_OID: string;
_Nome: string;
_CNPJ: string;
_Ativo: boolean;
TipoFornecedor?: TipoFornecedorXML;
}
export interface TipoFornecedorXML {
_OID: string;
_Titulo: string;
}
// abertura: backend devolve objeto novo + listas de apoio
export interface FornecedorInicialResponse {
Fornecedor: FornecedorXML;
TiposFornecedor: TipoFornecedorXML[];
}
export interface BuscaFornecedoresRequest {
OBJECTID: {
_Nome: string;
_Ativo: boolean;
};
}
export interface BuscaFornecedoresResponse {
Response: FornecedorXML[];
}
export interface SalvaFornecedorRequest {
Fornecedor: {
_OID: string;
_Nome: string;
_CNPJ: string;
TipoFornecedor?: { _OID: string };
};
}
3. Hooks de caso de uso
// src/pages/Cadastro/Fornecedor/service/hooks.ts
import { useCurioMutation } from "@/hooks/useCurioMutation";
import { FORNECEDOR_RMS } from "./constants";
import {
BuscaFornecedoresRequest,
BuscaFornecedoresResponse,
FornecedorInicialResponse,
SalvaFornecedorRequest
} from "./interfaces";
export const useIncluiFornecedor = () => useCurioMutation<FornecedorInicialResponse, void>(FORNECEDOR_RMS.OBTEM_DADOS);
export const useBuscaFornecedores = () =>
useCurioMutation<BuscaFornecedoresResponse, BuscaFornecedoresRequest>(FORNECEDOR_RMS.BUSCAR);
export const useSalvaFornecedor = () => useCurioMutation<void, SalvaFornecedorRequest>(FORNECEDOR_RMS.SALVAR);
4. Schemas
// src/pages/Cadastro/Fornecedor/schemas.ts
import { z } from "zod";
export const fornecedorSchema = z.object({
_Nome: z.string().min(1, "Informe o nome").max(120, "Máximo de 120 caracteres"),
_CNPJ: z
.string()
.min(1, "Informe o CNPJ")
.regex(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, "CNPJ inválido"),
_TipoFornecedor: z.string().min(1, "Selecione o tipo")
});
export type FornecedorFormData = z.infer<typeof fornecedorSchema>;
export const filtroFornecedorSchema = z.object({
_Nome: z.string(),
_Ativo: z.boolean()
});
export type FiltroFornecedorData = z.infer<typeof filtroFornecedorSchema>;
5. Estilos
// src/pages/Cadastro/Fornecedor/FornecedorPage.styles.ts
import { SxProps, Theme } from "@mui/material";
export const containerStyle: SxProps<Theme> = {
display: "flex",
flexDirection: "column",
gap: 2,
p: 3
};
export const headerBarStyle: SxProps<Theme> = {
display: "flex",
justifyContent: "flex-end",
gap: 1
};
export const filtroStyle: SxProps<Theme> = {
display: "flex",
gap: 2,
alignItems: "flex-start",
flexWrap: "wrap"
};
export const formSectionStyle: SxProps<Theme> = {
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2
};
Nomes de constante nomeada, não um objeto styles — ver 12.
6. A página
// src/pages/Cadastro/Fornecedor/FornecedorPage.tsx
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Divider, Typography } from "@mui/material";
import { Save, Search } from "@mui/icons-material";
import { UseCaseManager } from "@curio/client/react";
import { useAuth } from "@context/AuthProvider";
import { useUseCaseControls, useValidatedForm } from "@hooks/index";
import { DataTable, FormField, SelectInput, type Column, type SelectOption } from "@components/common";
import {
fornecedorSchema,
filtroFornecedorSchema,
type FornecedorFormData,
type FiltroFornecedorData
} from "./schemas";
import { FORNECEDOR_RMS } from "./service/constants";
import { useBuscaFornecedores, useIncluiFornecedor, useSalvaFornecedor } from "./service/hooks";
import type { FornecedorXML } from "./service/interfaces";
import { containerStyle, filtroStyle, formSectionStyle, headerBarStyle } from "./FornecedorPage.styles";
const colunas: Column<FornecedorXML>[] = [
{ id: "_Nome", label: "Nome", minWidth: 200 },
{ id: "_CNPJ", label: "CNPJ", minWidth: 160 },
{ id: "_Ativo", label: "Ativo", align: "center", format: (value) => (value ? "Sim" : "Não") }
];
const FornecedorContent: React.FC = () => {
const { open, status } = useUseCaseControls();
const [fornecedores, setFornecedores] = useState<FornecedorXML[]>([]);
const { data: dadosIniciais, mutate: incluirFornecedor, isPending: isIniciando } = useIncluiFornecedor();
const { data: resultadoBusca, mutate: buscarFornecedores, isPending: isBuscando } = useBuscaFornecedores();
const { mutateAsync: salvarAsync, isPending: isSalvando } = useSalvaFornecedor();
// refs impedem reabrir/reinicializar a cada render
const hasAttemptedOpenRef = useRef(false);
const hasInitializedRef = useRef(false);
// maquina de estados: idle -> abre; open -> inicializa
useEffect(() => {
if (status === "idle" && !hasAttemptedOpenRef.current) {
hasAttemptedOpenRef.current = true;
open();
} else if (status === "open" && !hasInitializedRef.current) {
hasInitializedRef.current = true;
incluirFornecedor();
}
}, [status, open, incluirFornecedor]);
useEffect(() => {
if (resultadoBusca) setFornecedores(resultadoBusca.Response ?? []);
}, [resultadoBusca]);
const tiposFornecedor: SelectOption[] = useMemo(
() => (dadosIniciais?.TiposFornecedor ?? []).map((tipo) => ({ value: tipo._OID, label: tipo._Titulo })),
[dadosIniciais]
);
const form = useValidatedForm({
schema: fornecedorSchema,
defaultValues: { _Nome: "", _CNPJ: "", _TipoFornecedor: "" }
});
const filtroForm = useValidatedForm({
schema: filtroFornecedorSchema,
defaultValues: { _Nome: "", _Ativo: true }
});
const handleBuscar = (data: FiltroFornecedorData) => {
buscarFornecedores({ OBJECTID: { _Nome: data._Nome, _Ativo: data._Ativo } });
};
const handleSalvar = async (data: FornecedorFormData) => {
await salvarAsync({
Fornecedor: {
_OID: String(dadosIniciais?.Fornecedor._OID ?? ""),
_Nome: data._Nome,
_CNPJ: data._CNPJ,
TipoFornecedor: { _OID: data._TipoFornecedor }
},
msgSucesso: "Fornecedor salvo com sucesso!"
});
form.reset();
incluirFornecedor(); // novo objeto pro proximo cadastro
filtroForm.handleSubmit(handleBuscar)();
};
const isAnyLoading = isIniciando || isBuscando || isSalvando;
return (
<Box sx={containerStyle}>
<Box sx={headerBarStyle}>
<Button
variant="contained"
startIcon={<Save />}
onClick={form.handleSubmit(handleSalvar)}
disabled={isAnyLoading}>
Salvar
</Button>
</Box>
<Typography variant="h3">Cadastro de fornecedor</Typography>
<Box sx={formSectionStyle}>
<FormField name="_Nome" control={form.control} label="Nome" size="small" fullWidth />
<FormField name="_CNPJ" control={form.control} label="CNPJ" size="small" fullWidth />
<SelectInput
name="_TipoFornecedor"
control={form.control}
label="Tipo"
options={tiposFornecedor}
size="small"
fullWidth
/>
</Box>
<Divider />
<Typography variant="h4">Fornecedores cadastrados</Typography>
<Box sx={filtroStyle}>
<FormField name="_Nome" control={filtroForm.control} label="Filtrar por nome" size="small" />
<Button
variant="outlined"
startIcon={<Search />}
onClick={filtroForm.handleSubmit(handleBuscar)}
disabled={isBuscando}>
Buscar
</Button>
</Box>
<DataTable
columns={colunas}
data={fornecedores}
loading={isBuscando}
emptyMessage="Nenhum fornecedor encontrado."
stickyHeader
maxHeight={400}
/>
</Box>
);
};
const FornecedorPage: React.FC = () => {
const { session } = useAuth();
return (
<UseCaseManager session={session} useCaseId={FORNECEDOR_RMS.USE_CASE} autoClose={false} openOnMount={false}>
<FornecedorContent />
</UseCaseManager>
);
};
export default FornecedorPage;
7. Barrel da página
// src/pages/Cadastro/Fornecedor/index.ts
export { default } from "./FornecedorPage";
Obrigatório. Sem ele, o lazy(() => import("@pages/Cadastro/Fornecedor")) falha em runtime sem erro
de compilação — o TypeScript não verifica o alvo do import dinâmico.
8. Caminho
// src/routes/paths.ts
export const PATHS = {
LOGIN: "login",
DASHBOARD: "dashboard",
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor" // novo
}
} as const;
9. Rota
// src/routes/routes.ts
export const routes: AppRoute[] = [
// ...
{
path: PATHS.CADASTRO.FORNECEDOR,
element: lazy(() => import("@pages/Cadastro/Fornecedor")),
guard: "protected"
}
];
10. Menu
// src/routes/menuTree.ts
export const menuTree: MenuNode[] = [
{ label: "Dashboard", path: PATHS.DASHBOARD, mode: "route" },
{
label: "Cadastro",
children: [{ label: "Fornecedor", path: PATHS.CADASTRO.FORNECEDOR }] // modo default: "tab"
}
];
Árvore final
src/pages/Cadastro/Fornecedor/
├── FornecedorPage.tsx
├── FornecedorPage.styles.ts
├── schemas.ts
├── index.ts
└── service/
├── constants.ts
├── hooks.ts
└── interfaces.ts
Mais três arquivos tocados: routes/paths.ts, routes/routes.ts, routes/menuTree.ts.
Verificação
Sem suíte de testes, esta é a evidência. Rode tudo.
Estático
npm run lint && npx tsc --noEmit
Funcional — npm run dev, então:
Falha proposital
Só depois de tudo isso registre a feature como passing no harness/state/feature_list.json —
ver 19.
Erros comuns
| Sintoma | Causa |
|---|---|
| Tela em branco, console: hook fora de contexto |
useCurioMutation no mesmo componente que renderiza o UseCaseManager
|
| Select "Tipo" vazio | Caso de uso não abriu; verifique status e o id |
| Import dinâmico falha em runtime | Faltou index.ts na pasta da página |
| Item no menu leva a 404 ou "Rota não encontrada" | Registrado em menuTree mas não em routes.ts
|
Rota duplica barra (//cadastro) |
PATHS com barra inicial |
| Requests repetidos em loop | Faltou o useRef de guarda no useEffect
|
| Backend recebe id/RM diferente do esperado | Constante duplicada fora de service/constants.ts, divergindo do _RMS
|
18 — Harness
18 — Harness
Disciplina de sessão: guias versionados, estado por branch, tudo versionado exceto preferências pessoais do dev. Ao criar em projeto novo, invoque a skill
anthropic-skills:curio-harness-bootstrap— ela é a fonte canônica dos templates.Reestruturado em 2026-08-25 (branch-primeiro, sem
global_feature_list.json) — ver nota de migração ao final.
O problema que isso resolve
Em projeto grande tocado por várias pessoas com Claude Code, três coisas quebram:
- Contexto se perde entre sessões. Cada sessão redescobre o que a anterior já sabia.
- Dois devs pisam no mesmo trabalho. Ninguém vê o que o outro está fazendo.
- "Está pronto" vira afirmação sem evidência. Código escrito é confundido com código verificado.
A solução não é framework — é convenção de arquivos. Guias versionados (o que o Claude precisa saber
antes de mexer), estado de sessão por branch (sem conflito entre branches), e um init.sh que
verifica baseline antes e depois.
Estrutura
Fica na raiz do repositório, ao lado de CLAUDE.md e init.sh. Não dentro de src/ nem de docs/.
projeto/
├── CLAUDE.md ponto de entrada (carregado automaticamente)
├── init.sh verificação de baseline — ver 19
└── harness/
├── guides/ referência técnica — VERSIONADO
├── _examples/
│ ├── state/ templates: feature_list.example.json, progress.example.md,
│ │ session-handoff.example.md
│ ├── handoffs/ template: handoff.example.md
│ └── user_preferences.example.md
├── <branch>/ tudo que a branch produziu — VERSIONADO
│ ├── state/ feature_list.json, progress.md, session-handoff.md
│ ├── plans/ planos de implementação (task-by-task)
│ ├── specs/ specs de design
│ └── handoffs/ handoffs de sessão
└── user_preferences.md preferências do dev — ÚNICO ARQUIVO LOCAL (gitignored)
<branch> é o nome literal da branch (git rev-parse --abbrev-ref HEAD). Branch com / no nome vira
subpasta naturalmente — feature/x → harness/feature/x/.
A distinção que importa
| Versionado | Local (gitignored) |
|---|---|
guides/, _examples/, tudo dentro de <branch>/
|
user_preferences.md |
| Todo o trabalho de sessão, por branch | Preferência pessoal do dev, não é da branch |
Trocar de branch agora troca de estado automaticamente — git checkout já isola o harness/<branch>/
de qualquer outra. Não existe mais "estado local do dev": o estado é da branch, e é versionado com ela.
.gitignore
# Harness (Claude Code) — so preferencia pessoal e' local
harness/user_preferences.md
Todo o resto — guides/, _examples/, e tudo dentro de harness/<branch>/ — é versionado.
guides/ — quais escrever
Um projeto fullstack usa guias de backend além dos de frontend. Um projeto só de frontend usa um subconjunto.
Escrever
| Guia | Conteúdo |
|---|---|
commands.md |
Build, run, ambientes, variáveis. Comandos reais, confirmados |
architecture.md |
Camadas do front, fluxo até o backend, autenticação, roteamento |
curio-framework-guide.md |
Sessão, caso de uso, useCurioMutation, React Query, tratamento de erro |
dev-environment.md |
Node/fnm, .env, acesso aos servidores por ambiente |
husky-git-hooks.md |
O que cada hook faz, como configurar, armadilhas de sh -e e source
|
Não escrever (são de backend)
curio-backend-syntax.md, curio-spring-boot-guide.md, staruml-tooling.md, test-data-scripts.md.
Se o projeto novo tiver backend próprio, aí sim — mas isso está fora do escopo deste guia.
Condicionais
-
gitlab-access.md— só se scripts do projeto usarem a API do GitLab (GITLAB_PRIVATE_TOKEN). Nunca commitar o valor do token, só a variável de ambiente. - Glossário de domínio (ex.: um
arquitetura-dominio-<assunto>.md) — só se o domínio for genuinamente intrincado. É investigação cara; pergunte antes de criar.
Regra ao escrever um guia
Investigue o código real. Não escreva de memória do que "Curio costuma ser" — pode ser outra versão
do framework, outra convenção, outro layout. Se um comando não foi confirmado (rodado ou lido do
package.json), não o documente.
<branch>/state/ — estado da branch
feature_list.json
{
"project": "{{NOME_DO_PROJETO}}",
"project_type": "frontend",
"last_updated": "YYYY-MM-DD",
"rules": {
"passing_requires_evidence": true,
"do_not_skip_verification": true
},
"status_legend": {
"not_started": "Work has not begun.",
"in_progress": "The feature is the current active task.",
"blocked": "Work cannot continue until a documented blocker is resolved.",
"passing": "Required verification has passed and evidence is recorded."
},
"features": [
{
"id": "exemplo-001",
"type": "ui",
"area": "nome-da-area",
"title": "Título curto da feature",
"behavior": "Descrição do comportamento esperado/pendência.",
"verification": "Como confirmar que está correto (comando, tela, checklist).",
"status": "not_started",
"evidence": "Preenchido só quando houver evidência real de verificação."
}
],
"completed": []
}
Num projeto só de frontend, type é predominantemente "ui" — a verificação é abrir a tela e conferir
o fluxo, não rodar teste.
O array features guarda o que ainda não é passing. Ao concluir, a entrada sai de features e
entra em completed — histórico da branch, versionado, sem cópia para nenhum arquivo global.
progress.md
# Session Progress Log (versionado — por branch)
## Current State
**Last Updated:** YYYY-MM-DD
**Active Feature:** o que você está trabalhando agora
## Sessão YYYY-MM-DD (parte N) — título curto
- O que foi feito, decisões tomadas, bugs encontrados.
- Comandos de verificação rodados e resultado.
- Pendências para a próxima sessão.
## Status
### What's Done
- [ ] ...
### What's In Progress
- [ ] ...
### What's Next
1. ...
## Blockers / Risks
- [ ] ...
session-handoff.md
# Session Handoff (versionado — por branch)
## Current Objective
- Goal: ...
- Active feature (from harness/<branch>/state/feature_list.json): ...
- Branch / commit: ...
## Completed This Session
- [x] ...
## Verification Evidence
| Check | Command | Result | Notes |
| ----- | ------- | ------ | ----- |
| ... | ... | ... | ... |
## Files Changed
- ...
## Decisions Made
- ...
## Blockers / Risks
- ...
## Next Session Startup
1. Ler `CLAUDE.md` (seção "Harness")
2. Resolver a branch atual (`git rev-parse --abbrev-ref HEAD`) e ler `harness/user_preferences.md`
(se existir) e `harness/<branch>/state/progress.md`
3. Rodar `./init.sh` (FAST) antes de editar
## Recommended Next Step
- ...
Regra inegociável
feature_list.json e progress.md nunca nascem pré-preenchidos. Só os _examples/ são criados no
bootstrap. harness/<branch>/state/ é criado (scaffolding a partir dos _examples/) na primeira sessão
daquela branch — mas o conteúdo é preenchido por quem trabalha, não pelo Claude "para ajudar".
Se harness/<branch>/state/ não existir ainda, o Claude deve criar a pasta a partir dos _examples/
(mecânico) e recomendar preencher — não inventar feature_list.json/progress.md com valores
supostos.
plans/ e specs/
-
<branch>/plans/*.md— planos de implementação task-by-task, com checkboxes. Nome:YYYY-MM-DD-nome-da-feature.md. -
<branch>/specs/*.md— specs de design para decisões pontuais. Nome:YYYY-MM-DD-nome-do-assunto-design.md.
Quando existir plano ou spec da feature ativa, ele tem precedência sobre o feature_list.json
genérico para o detalhe fino. Atualize os dois ao final da sessão.
Regra de proveniência: um plano/spec fica na branch onde foi criado, mesmo que o trabalho continue depois em outra branch. Não é para realocar quando isso acontecer — é só registro de onde nasceu.
Se o projeto já tem planos/specs soltos em outro lugar (ex.:
docs/superpowers/plans/edocs/superpowers/specs/, de um uso anterior da skillsuperpowers), esse conteúdo passa a viver emharness/<branch>/plans/eharness/<branch>/specs/.Nota de compatibilidade: a skill
superpowerspode assumir o caminho antigodocs/superpowers/. Se ela não encontrar os arquivos no novo local, ajuste a skill — não volte a mover os arquivos.
handoffs/ e user_preferences.md
-
<branch>/handoffs/*.md— handoff de uma feature específica, versionado. Complementa osession-handoff.md, que cobre a sessão mais recente de qualquer feature naquela branch. -
user_preferences.md— preferências pessoais do dev (modo padrão doinit.sh, verbosidade, ambiente local, convenções de commit). Único arquivo local, criado a partir de_examples/user_preferences.example.md. Não é da branch — é da pessoa, atravessa todas as branches.
Bootstrap em projeto novo
Invoque a skill anthropic-skills:curio-harness-bootstrap. Ela traz os templates já validados,
o init.sh.template e a seção de CLAUDE.md. Não reconstrua a estrutura lendo outro projeto na mão —
use os templates da skill como fonte, para não divergir silenciosamente.
Passos:
- Criar
harness/guides/eharness/_examples/(nomes exatos acima — não invente variação). - Copiar os templates para dentro de
_examples/(state/,handoffs/,user_preferences.example.md). - Adicionar
harness/user_preferences.mdao.gitignore— só ele. - Escrever os guias a partir do código real do projeto.
- Gerar o
init.shcom os comandos reais — ver 19. - Inserir a seção de harness no
CLAUDE.md— ver 16, incluindo o passo de resolver a branch atual e criarharness/<branch>/state/a partir dos_examples/. -
Rodar o
init.sh(FAST) e confirmar que passa.
Não existe mais passo de criar global_feature_list.json — foi eliminado (ver nota abaixo).
O que não fazer
- Não copie os guias de outro projeto achando que "é o mesmo framework". Pode ser outra versão do Curio, outra linguagem, outras convenções. Sempre reescreva a partir da investigação do projeto alvo.
-
Não crie
<branch>/state/feature_list.jsonouprogress.mdpreenchidos "para ajudar" — só a pasta, a partir dos_examples/; o conteúdo é de quem trabalha. -
Não pule a execução do
init.sh. Script quebrado passa despercebido até a primeira sessão real, quando já é tarde. -
Não recrie
global_feature_list.json. Foi removido deliberadamente — ver nota de migração abaixo.
Reestruturação 2026-08-25: por que branch-primeiro
Problema encontrado: harness/state/feature_list.json (+ progress.md, session-handoff.md) era
um único arquivo, compartilhado entre todas as branches — apesar do .gitignore marcar state/* como
local, os arquivos já estavam commitados de antes. Trocar de branch sobrescrevia o estado de outra
branch. Essa foi a causa raiz de um bug real observado num projeto que segue este padrão.
Decisão: layout branch-primeiro. Cada branch ganha harness/<branch>/ com state/, plans/,
specs/ e handoffs/ próprios, tudo versionado — sem gitignore para nada disso.
O que foi removido: global_feature_list.json (histórico compartilhado entre devs/branches de
features passing). Decisão explícita: aceitar perder a visibilidade cross-branch em troca de
simplicidade — cada branch agora é autocontida. Não recrie esse arquivo achando que é uma omissão.
Migração de histórico pré-existente: se um projeto já tem harness no formato antigo (um state/
único, global_feature_list.json) e vai migrar para este layout, os plans//specs/ antigos devem ser
reatribuídos à branch onde nasceram — via git log --merges (merge commits guardam a branch de
origem mesmo depois dela ser deletada: para cada arquivo, ache o commit de criação e caminhe pelos
merges até achar aquele em que o commit é ancestral só do segundo parent, não do primeiro). Não deixe
esse histórico solto numa pasta _legacy — reatribua de fato.
19 — `init.sh` e Definition of Done
19 — init.sh e Definition of Done
Um script que verifica o baseline do projeto antes e depois de cada sessão, em dois modos. E a definição de quando uma tarefa está de fato concluída. Um
init.shque não roda é pior que nenhum. Execute-o antes de dizer que terminou.
Por que existe
Sem baseline verificado, uma sessão começa sobre terreno já quebrado e o dev gasta uma hora
depurando um erro que não era dele. O init.sh responde a uma pergunta em segundos: o projeto está
são agora?
Roda no início da sessão (o terreno está limpo?) e no fim (eu quebrei algo?).
FAST e FULL
A distinção é o que faz o script ser usado de fato, em vez de pulado por demorar.
| Modo | Invocação | O que roda | Quando |
|---|---|---|---|
| FAST | ./init.sh |
Lint + type-check | Toda hora, várias vezes ao dia |
| FULL | FULL=1 ./init.sh |
+ format:check + build completo |
Antes de considerar pronto |
FAST precisa terminar em segundos. Se passar de ~30s, algo está no modo errado.
O script
#!/usr/bin/env bash
# init.sh — verifica o baseline do projeto antes/depois de uma sessao.
#
# Uso:
# ./init.sh # rapido (padrao): lint + type-check
# FULL=1 ./init.sh # completo: + format:check + build
#
# Pre-requisitos:
# - Node >= 24 no PATH (gerenciado via fnm — ver harness/guides/dev-environment.md)
# - npm install ja rodado
set -euo pipefail
FULL="${FULL:-0}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "== {{NOME_DO_PROJETO}} — init.sh (modo $([ "$FULL" = "1" ] && echo FULL || echo FAST)) =="
echo ""
echo "--- [1/4] Node ---"
node_expected=$(tr -d '[:space:]' < "$ROOT_DIR/.node-version")
node_actual=$(node -v 2>/dev/null | sed 's/^v//')
if [ -z "$node_actual" ]; then
echo "ERRO: 'node' nao encontrado no PATH. Rode 'fnm use'."
exit 1
fi
lower=$(printf '%s\n%s\n' "$node_expected" "$node_actual" | sort -V | head -n1)
if [ "$lower" != "$node_expected" ]; then
echo "ERRO: Node v$node_actual encontrado, necessario >= v$node_expected."
exit 1
fi
echo "Node v$node_actual OK"
echo ""
echo "--- [2/4] Lint ---"
(cd "$ROOT_DIR" && npm run lint)
echo ""
echo "--- [3/4] Type-check ---"
(cd "$ROOT_DIR" && npx tsc --noEmit)
if [ "$FULL" = "1" ]; then
echo ""
echo "--- [4/4] Formatacao + build ---"
(cd "$ROOT_DIR" && npm run format:check)
(cd "$ROOT_DIR" && npm run build)
else
echo ""
echo "--- [4/4] Build — PULADO (rode com FULL=1 para incluir) ---"
fi
echo ""
echo "== init.sh OK =="
Projeto web dentro de um monorepo
Se o web for submódulo de um monorepo, o init.sh fica na raiz do repositório e entra na pasta do
módulo:
WEB_DIR="$ROOT_DIR/caminho/do/modulo"
echo "--- [2/4] Lint ---"
(cd "$WEB_DIR" && npm run lint)
Decisões do script
| Item | Razão |
|---|---|
set -euo pipefail |
Aborta no primeiro erro; variável indefinida vira falha, não string vazia |
| Checagem de Node primeiro | Erro claro em vez de falha incompreensível do npm |
npx tsc --noEmit no FAST |
Barato e pega o que o checker do dev não viu |
build só no FULL |
Demora demais para rodar a toda hora |
format:check, não format
|
Verificação não deve reescrever arquivo |
Não copie comandos de outro projeto (ex.: ./mvnw compile, mvn test de um projeto fullstack
Java+React). Os comandos precisam ser os reais deste projeto.
Rodar o script
chmod +x init.sh
./init.sh
No Windows, execute pelo Git Bash. Se o time for todo Windows, considere um init.ps1 equivalente —
mas mantenha um só como fonte de verdade, para não divergirem.
Execute o script depois de gerá-lo. Isso não é sugestão: um init.sh que nunca rodou tem
probabilidade alta de estar quebrado (caminho errado, script npm inexistente, set -e derrubando num
comando que retorna não-zero legitimamente).
Startup Workflow
Antes de escrever código:
-
pwd— confirme o diretório. - Resolva a branch atual (
git rev-parse --abbrev-ref HEAD). Seharness/<branch>/state/não existir, crie a partir deharness/_examples/(scaffolding mecânico, não preenchimento de conteúdo). - Leia
harness/user_preferences.md(se existir). - Leia
harness/<branch>/state/feature_list.jsoneprogress.md(se existirem). -
git log --oneline -5. -
./init.sh.
Se o baseline já estiver quebrado, corrija antes de implementar feature nova. Misturar correção de baseline com feature nova produz um diff que ninguém consegue revisar.
Definition of Done
Uma tarefa só está concluída quando:
O item do browser não é opcional
Não há suíte de testes (14). lint e tsc provam que o código
compila, não que funciona. A única evidência funcional que existe neste padrão é abrir a tela e
verificar o fluxo. Ver o checklist de 17.
Evidência é texto, não adjetivo
// RUIM — nao e' evidencia
"evidence": "Implementado e testado."
// BOM — verificavel
"evidence": "Implementado em 2026-08-18. ./init.sh FULL verde. Tela cadastro/fornecedor aberta em dev:
busca com filtro 'ACME' retornou 3 registros; salvar com CNPJ invalido bloqueou no zod; salvar valido
mostrou notificacao verde e o registro apareceu na tabela apos rebusca. Sem erro no console."
Não marque como passing só porque o código foi escrito. É o erro mais comum, e o que torna o
feature_list.json inútil.
Fim de sessão
- Atualizar
harness/<branch>/state/feature_list.json(status + evidência): se algo chegou apassing, mover a entrada defeaturesparacompletedno mesmo arquivo — sem cópia para nenhum arquivo global, não existe mais (ver 18). - Atualizar
harness/<branch>/state/progress.md. - Atualizar
harness/<branch>/state/session-handoff.mdcom o resultado da verificação. - Commit descritivo com o repositório em estado seguro — inclui
state/,plans/,specs/ehandoffs/da branch, tudo versionado. - A próxima sessão deve conseguir rodar
./init.shimediatamente.
O passo 5 é o teste do fim de sessão: se a próxima pessoa não consegue verificar o baseline sem antes consertar algo, a sessão não fechou.
Se o projeto adotar testes
Se 14 for revisto e o projeto passar a ter suíte:
- Adicione
npm testao bloco FULL doinit.sh. - Adicione "testes passando" à Definition of Done.
- Mantenha o item da verificação no browser — teste unitário não prova integração com o Curio.
20 — Ciclo de desenvolvimento
20 — Ciclo de desenvolvimento
Do recebimento da tarefa ao merge na master: issue, branch, commits, MR, review. Processo pensado para projeto só de frontend, com o harness integrado. Este documento cobre como o trabalho flui; os anteriores cobrem como o código se organiza.
Referência rápida
tarefa recebida
→ clarificação (contexto, escopo, critérios, dependências)
→ issue aberta com template + labels + assignee
→ branch criada: <id-issue>-<descricao-curta>
→ ./init.sh antes de começar (baseline limpo)
→ sync com master
→ desenvolvimento com commits semânticos
→ ./init.sh FAST antes de cada commit
→ ./init.sh FULL + verificação no browser antes do MR
→ sync com master antes de abrir MR
→ MR aberto com template + reviewer
→ code review → ajustes → aprovação
→ merge → issue fechada
→ fim de sessão do harness (feature_list, progress, handoff)
1. Recebimento da tarefa
Antes de abrir issue, você precisa conseguir responder as quatro perguntas. Se não conseguir, peça uma reunião curta (15 min) com quem trouxe a demanda.
| Pergunta | O que responder |
|---|---|
| Contexto | Por que essa tarefa existe? Que problema de negócio resolve? |
| Escopo | O que está dentro e fora dessa entrega? |
| Critérios de aceite | Como saberemos que está pronto? Quem valida? |
| Dependências | Algo precisa estar pronto antes? Outro time envolvido? |
Num projeto Curio há uma quinta pergunta, específica: o caso de uso do backend já existe? Se a tela depende de um caso de uso ainda não implementado, isso é dependência bloqueante — registre.
2. Issue no GitLab
Template
.gitlab/issue_templates/default.md:
## Contexto
<!-- Por que essa tarefa existe? O que motivou essa demanda? -->
## O que fazer
<!-- Descrição objetiva e clara do que precisa ser entregue. -->
## Critérios de aceite
- [ ]
- [ ]
## Notas técnicas
<!-- Id do caso de uso, nomes de request, impacto em outras telas.
Deixe em branco se não houver. -->
## Referências
<!-- Design no Figma, documentação, issue relacionada. -->
Título
Padrão [Tipo] Verbo + objeto:
[Feature] Adicionar tela de cadastro de fornecedor
[Bug] Corrigir data de expiração enviada sem sufixo de hora
[Chore] Atualizar dependências para versão LTS
[Refactor] Extrair filtro de busca para componente comum
Labels
Dois eixos:
| Eixo | Labels |
|---|---|
| Tipo |
type::feature type::bug type::chore type::refactor
|
| Prioridade |
priority::critical priority::high priority::medium priority::low
|
Assignee é obrigatório. Issue sem dono não entra no sprint.
Definition of Ready
Uma issue só entra no sprint quando:
3. Branch
Crie a partir da issue no GitLab — o id vem automaticamente. Sempre a partir da master atualizada.
<id-issue>-<descricao-curta>
123-cadastro-fornecedor
456-corrigir-data-expiracao
789-atualizar-dependencias
4. Antes de começar
Rode o Startup Workflow do harness (19):
git fetch origin && git merge origin/master
./init.sh
Se o baseline já estiver quebrado, corrija antes de começar a feature. Misturar conserto de baseline com feature nova produz um diff irrevisável.
Mantenha a branch sincronizada com a master ao menos uma vez por dia, e sempre antes de abrir o MR.
5. Commits
Conventional Commits:
<tipo>: descrição curta
| Tipo | Quando usar |
|---|---|
feat |
nova funcionalidade |
fix |
correção de bug |
refactor |
refatoração sem mudança de comportamento |
docs |
documentação |
chore |
manutenção, dependências |
perf |
melhoria de performance |
test |
testes — só se o projeto adotar suíte |
feat: adicionar tela de cadastro de fornecedor
fix: corrigir data de expiração enviada sem sufixo de hora
refactor: extrair filtro de busca para componente comum
Princípios
- Cada commit faz uma coisa só
- O baseline passa após cada commit
- A mensagem explica o porquê, não o o quê
Verificação antes de cada commit
./init.sh
O pre-commit já roda lint-staged, versão de Node e checagem de flags de debug
(14) — mas ele só vê os arquivos staged. O init.sh FAST vê o
projeto inteiro.
Não suba código que quebra o baseline. Isso bloqueia o time e polui o histórico.
Se este projeto for só frontend, ignore quaisquer instruções de build Java/Delphi herdadas de um processo combinado com backend — não se aplicam.
6. Merge Request
Template
.gitlab/merge_request_templates/default.md:
## O que foi feito
<!-- Breve descrição das mudanças. -->
## Issue relacionada
Closes #
## Como testar
1.
2.
## Screenshots (se aplicável)
<!-- Antes/depois para mudanças visuais. -->
## Checklist
- [ ] `./init.sh FULL` passando localmente
- [ ] Tela aberta no browser e fluxo verificado contra o backend real
- [ ] Sem `console.log` ou código de debug (`enableLogs: true`)
- [ ] Documentação atualizada (se necessário)
- [ ] Branch atualizada com a master
- [ ] `harness/state/feature_list.json` e `progress.md` atualizados
Ajuste o checklist ao que o projeto realmente tem. Um template herdado de outro processo pode pedir itens como "Testes escritos ou atualizados" e "Pipeline passando" mesmo quando o projeto não tem suíte de testes nem
.gitlab-ci.yml(15). Checklist que pede o inexistente treina o time a marcar caixa sem ler. Se o projeto novo adotar CI, acrescente a linha; se não, não a coloque.
Configuração no GitLab
- Aprovação mínima de 1 reviewer
- Pipeline obrigatória para habilitar o merge — se houver pipeline
7. Code review
Para quem revisa
- Leia a descrição do MR antes do código — entenda o contexto primeiro
- Comente com intenção explícita:
-
nit:— sugestão menor, não bloqueia -
suggestion:— sugestão de melhoria, bloqueia num primeiro momento -
blocking:— precisa ser resolvido antes do merge
-
- Questione o porquê, não só o como
- Priorize lógica e segurança; estilo é papel do linter
- Aprove se o código está correto e legível. Não exija que seja idêntico ao que você faria
Para quem abre
- Responda todos os comentários antes de pedir re-review
- Não faça force push depois de abrir o MR — quebra o histórico de revisão
- Marque como resolvido ao aplicar a correção
- Atenda ao que o revisor observa, mesmo sendo pequeno — exceto em demanda urgente
O que observar
| Categoria | O que verificar |
|---|---|
| Baseline |
./init.sh passa; sem warning novo |
| Lógica | Edge cases não cobertos, condições incorretas |
| Curio | Caso de uso aberto antes do request; guarda por useRef; sem reabrir |
| Erro | Sem try/catch só para exibir mensagem — o global já notifica (07) |
| Segurança | Dado exposto, validação ausente, segredo commitado |
| Performance | Chamada redundante, request em loop, lista sem virtualização |
| Legibilidade | Nome autoexplicativo, função com responsabilidade única |
| Convenções |
_Prefixo, barrel atualizado, alias nos dois configs (13) |
8. Merge
- Só com pipeline verde — se houver pipeline
-
Closes #123no MR fecha a issue automaticamente
9. Fim de sessão
Depois do merge, feche o ciclo no harness (19):
-
harness/state/feature_list.json— status + evidência - Se chegou a
passing, copiar paraharness/global_feature_list.json -
harness/state/progress.md— o que foi feito, bloqueios, próximo passo -
harness/state/session-handoff.md— resultado da verificação
A próxima sessão precisa conseguir rodar ./init.sh imediatamente.
22 — Testes E2E com Playwright
22 — Testes E2E com Playwright
Guia para configurar Playwright do zero em projeto Vite + React. Consolidado a partir de uma configuração real, testada de verdade — as armadilhas na seção 11 aconteceram de fato, não são hipotéticas.
Sumário
- Conceitos essenciais do Playwright
- Instalação
- Estrutura de pastas sugerida
- Configuração (
playwright.config.ts) - Variáveis de ambiente
- Setup de login (sessão reutilizável)
- Fixtures + Page Object Model
- Exemplo de spec genérico
- Seed/cleanup de dados via API (opcional, avançado)
.gitignoree scripts dopackage.json- Decisões e armadilhas comuns
1. Conceitos essenciais do Playwright
O Playwright Test é um framework de testes ponta a ponta (E2E) com test runner, asserções, isolamento, paralelização e um conjunto rico de ferramentas. Suporta Chromium, Firefox e WebKit (Windows/Linux/macOS/CI), com emulação móvel nativa.
-
Isolamento em 3 camadas:
Browser(processo do navegador, caro, 1 por worker) →BrowserContext(cookies/localStorage/sessionStorage/cache isolados, barato, 1 por teste) →Page(aba dentro do contexto, não isola nada sozinha). -
Auto-waiting: antes de qualquer ação (
click,fill, etc.) o Playwright verifica actionability checks (visível, estável, habilitado...) e repete a checagem até passar ou estourar timeout — elimina a necessidade desleep().expect(locator).toBeVisible()é polling, não uma foto única;expect(await x.count()).toBe(n)é. -
Fixtures: injeção de dependência do Playwright (
page,context,browser,requestjá vêm nativas). SubstituibeforeEachrepetido por "pedir só o que a receita precisa". - Workers: cada worker é um processo do SO inteiro, com seu próprio browser; se um teste falha, o worker inteiro é descartado e recriado.
-
Paralelismo: por padrão, arquivos diferentes rodam em workers diferentes; testes do mesmo arquivo rodam em sequência no mesmo worker, a menos que
fullyParallel: true. -
Projects: configuração nomeada e independente (pode ter seu próprio browser/device/storageState/
testMatch). É a base do padrão de setup/cleanup usado abaixo.
Referência oficial de boas práticas: https://playwright.dev/docs/best-practices
2. Instalação
npm init playwright@latest
O instalador pergunta:
- TypeScript ou JavaScript;
- Nome do diretório de testes;
- Adicionar GitHub Actions workflow;
- Instalar os browsers do Playwright.
Dependências relevantes (via devDependencies):
"@playwright/test": "^1.62.1",
"dotenv": "^16.4.5",
"@types/node": "^24.13.3"
dotenv é necessário porque o playwright.config.ts lê variáveis de um .env próprio dos testes (credenciais de login E2E), separado da config runtime da aplicação.
Comandos de execução:
npx playwright test # roda a suíte
npx playwright test --ui # interface gráfica (recomendada pela doc oficial)
Extensão VS Code: instale a extensão oficial "Playwright Test for VSCode" — permite gravar interações (Record new) e gera o código do teste automaticamente a partir de cliques reais na aplicação.
3. Estrutura de pastas sugerida
tests/
├── base.ts # fixtures customizadas + decorator @step
├── e2e/
│ └── <dominio>/
│ └── <feature>/
│ ├── <Feature>TestPage.ts # Page Object da tela
│ └── <feature>.spec.ts # spec que usa a Page Object
├── setup/
│ ├── login.setup.ts # roda antes de tudo, gera sessão reutilizável
│ ├── seed.setup.ts # opcional — garante dados de apoio via API
│ └── cleanup.teardown.ts # opcional — apaga dados de apoio via API
└── support/
├── apiClient.ts # cliente HTTP/RPC isolado, sem depender de src/
└── seeders/
├── types.ts # contrato Seeder
├── registry.ts # lista ordenada de seeders
└── <dominio>/
├── <dominio>.config.ts
└── <dominio>.seeder.ts
O que importa reter dessa organização: um Page Object por tela testada, testes de setup/seed/cleanup como projects isolados (não como beforeAll/afterAll dentro do spec), e o cliente de API de teste separado de qualquer código de src/.
4. Configuração (playwright.config.ts)
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, ".env") });
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [["junit", { outputFile: "results.xml" }], ["html", { open: "never" }]]
: "html",
expect: { timeout: 10_000 },
use: {
baseURL: "http://localhost:5173", // ajuste para a porta real do `vite dev`
trace: "on-first-retry"
},
projects: [
{
name: "login",
use: { ...devices["Desktop Chrome"] },
testMatch: /login\.setup\.ts/
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["login"],
testMatch: /.*\.spec\.ts/
}
// Adicione "seed"/"cleanup" como projects extras só se o projeto
// realmente precisar de dados de apoio via API (ver seção 9).
],
webServer: {
command: "npm run dev",
url: "http://localhost:5173", // igual ao baseURL acima
reuseExistingServer: !process.env.CI
}
});
Atenção à porta: é um erro comum apontar essa configuração para uma porta padrão (
localhost:3000) quando o Vite do projeto usa outra. Confirme a porta real donpm run devdo seu projeto antes de fixarbaseURL/webServer.url— é fácil de repetir.
Os projects login/chromium com dependencies são o mecanismo central: o Playwright sempre roda o project de login primeiro, e os specs (chromium) só começam depois que ele termina — sem precisar refazer login em cada teste.
5. Variáveis de ambiente
Crie um .env.example na raiz do projeto de testes (versionado) e um .env real (ignorado pelo git):
# Credenciais usadas pelo setup de login dos testes E2E (Playwright)
E2E_USER="USUARIO_DE_TESTE"
E2E_PASSWORD="SENHA_DE_TESTE"
6. Setup de login (sessão reutilizável)
tests/setup/login.setup.ts:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { test as setup, expect } from "@playwright/test";
const user = process.env.E2E_USER;
const password = process.env.E2E_PASSWORD;
if (user === undefined) throw new Error("E2E_USER não definido");
if (password === undefined) throw new Error("E2E_PASSWORD não definido");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sessionFilePath = path.resolve(__dirname, "../../playwright/.auth/sessionStorage.json");
setup("Setup Inicial de Login do Sistema", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(user);
await page.getByLabel("Senha").fill(password);
await page.getByRole("button", { name: "Entrar" }).click();
await expect(page.getByText("Nome do Sistema")).toBeVisible();
// Se a app usa sessionStorage (não localStorage) para guardar o token,
// `storageState()` nativo do Playwright não serve — precisa salvar na mão:
const sessionStorageData = await page.evaluate(() => JSON.stringify(window.sessionStorage));
fs.mkdirSync(path.dirname(sessionFilePath), { recursive: true });
fs.writeFileSync(sessionFilePath, sessionStorageData, "utf-8");
});
Se a aplicação alvo usa
localStorageem vez desessionStorage, use ostorageState()nativo do Playwright (context.storageState({ path })) — é mais simples e não exige oaddInitScriptmanual do passo 7. O caminho manual só é necessário quando o token fica emsessionStorage.
7. Fixtures + Page Object Model
tests/base.ts — fixture customizada (createTestPage) que injeta a sessão salva e fornece um decorator @step para nomear passos no relatório do Playwright:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { test as base, expect, Page } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sessionFilePath = path.resolve(__dirname, "../playwright/.auth/sessionStorage.json");
type PageObjectClass<T> = new (page: Page) => T;
export const test = base.extend<{
createTestPage: <T>(PageObject: PageObjectClass<T>) => T;
}>({
page: async ({ page }, use) => {
if (fs.existsSync(sessionFilePath)) {
const sessionStorageData = fs.readFileSync(sessionFilePath, "utf-8");
await page.addInitScript((storage) => {
const entries = JSON.parse(storage);
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value as string);
}
}, sessionStorageData);
}
await page.goto("/");
await use(page);
},
createTestPage: async ({ page }, use) => {
await use((PageObject) => new PageObject(page));
}
});
export { expect };
/** Decorator que envolve um método de Page Object num `test.step` nomeado. */
export function step(stepName?: string) {
return function decorator<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
function replacementMethod(this: This, ...args: Args): Return {
const name = `${stepName || (context.name as string)}`;
return test.step(name, async () => {
return target.call(this, ...args);
}) as Return;
}
return replacementMethod;
};
}
Todo spec deve importar test/expect deste arquivo, nunca direto de @playwright/test — é o que garante que a sessão salva seja injetada.
Page Object — uma classe por tela, cada teste vira um método público:
export class ItemTestPage {
constructor(private page: Page) {}
private async fillForm(name: string) {
// ... lógica de preenchimento
}
async setup() {
// ... navegação até a tela, pré-condições
}
async testPageLoad() {
// ... asserção de que a página carregou
}
async testCreate() {
// ... teste de criação
}
async testEdit() {
// ... teste de edição
}
async testDelete() {
// ... teste de exclusão
}
}
8. Exemplo de spec genérico
import { test } from "../../base";
import { ItemTestPage } from "./ItemTestPage";
test.describe("Testes para Cadastro de Item", () => {
test("Carregamento da página", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testPageLoad();
});
/**
* Testes em série — dependem do estado deixado pelo teste anterior.
* Fluxo: criar → editar → excluir.
*/
test.describe.serial("Testes de Fluxo do Usuário", () => {
test("Cadastro de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testCreate();
});
test("Edição de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testEdit();
});
test("Exclusão de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testDelete();
});
});
});
test.describe.serial é o que garante a ordem e compartilha falha em cadeia — se "Cadastro" falhar, os demais são pulados em vez de rodar contra um estado inconsistente.
9. Seed/cleanup de dados via API (opcional, avançado)
Só vale a pena se os testes dependem de dados de apoio (ex.: uma entidade pai que precisa existir antes da tela ser testável) e você quer evitar que cada spec crie/apague seus próprios dados.
Padrão recomendado:
-
tests/support/apiClient.ts: abre uma sessão direto contra o backend (sem passar pelo código desrc/), autenticando comE2E_USER/E2E_PASSWORD. Só existe porque o carregador ESM do Node (usado pelo Playwright) não resolve certos imports profundos sem extensão que o bundler do Vite tolera — se o seu backend for uma API REST comum, umfetch/client HTTP simples resolve sem essa complicação. -
tests/support/seeders/types.ts: contrato comumSeeder(name,seedAll(),teardownAll()). -
tests/support/seeders/registry.ts: lista ordenada de seeders — a ordem importa quando um seeder depende de dado criado por outro. -
tests/setup/seed.setup.ts(projectseed, roda antes dos specs): iteraSEEDERSchamandoseedAll(). -
tests/setup/cleanup.teardown.ts(projectcleanup, comteardown: "cleanup"no project principal): iteraSEEDERSna ordem inversa chamandoteardownAll(), para respeitar dependências ao apagar.
No playwright.config.ts, isso vira:
projects: [
{ name: "login", testMatch: /login\.setup\.ts/, use: { ...devices["Desktop Chrome"] } },
{ name: "seed", testMatch: /seed\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["login", "seed"],
teardown: "cleanup",
testMatch: /.*\.spec\.ts/
},
{ name: "cleanup", testMatch: /cleanup\.teardown\.ts/ }
]
Se o projeto novo não tiver essa necessidade de dados de apoio compartilhados, pule esta seção inteira — é a parte mais específica/avançada deste setup, não um requisito do Playwright em si.
10. .gitignore e scripts do package.json
Adicionar ao .gitignore:
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
É comum não haver um script dedicado ("test": "playwright test") no package.json, rodando os testes via npx playwright test diretamente. Vale considerar adicionar esse script no projeto novo desde o início:
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
11. Decisões e armadilhas comuns
-
Porta do
webServer/baseURLprecisa bater exatamente com a porta dovite dev— é um erro fácil de cometer e só aparece depois de já commitado. Confirme antes de bater o martelo. -
sessionStoragevslocalStorage: se a app-alvo guarda token/sessão emsessionStorage, ostorageState()nativo do Playwright não funciona — é preciso capturar manualmente compage.evaluateno setup e reinjetar compage.addInitScriptna fixture (seções 6 e 7). Se a app usalocalStorage, prefira o mecanismo nativo, é mais simples. -
Nunca importar
test/expectdireto de@playwright/testnos specs — sempre dotests/base.tslocal, senão a fixture de sessão não é aplicada. - Setup/seed/cleanup como projects separados, não como hooks dentro dos specs — isso é o que permite rodar uma vez só (login) ou uma vez por suíte inteira (seed/cleanup), em vez de repetir por arquivo/teste.
-
Teste em série (
test.describe.serial) só quando o fluxo realmente depende de estado anterior (criar → editar → excluir da mesma entidade); testes independentes devem ficar fora do bloco serial para poderem paralelizar.