Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ O atalho `scripts/verify.sh plan <issue> <ticket> [<commit de plano>]` chama o s

- **Desenho:** o contrato de rotas vai direto para o `apps/api/openapi.json`, e o schema das tabelas implementadas, para o `schema.prisma`: são os donos deles, conferidos por máquina. Como cada item do formato de rota e de tabela da skill se escreve neles está no `docs/lld.md` (seção 3, "Formato de uma rota no snapshot", e seção 2, "Formato de uma tabela no schema"). O LLD fica com as convenções, as regras e o que ainda não existe. A lista de arquivos e interfaces e a mudança de dados (seed, backfill) ficam na mensagem do commit de plano, como diz a skill em `formatos/mensagem.md`.
- **Congelamento:** o agente não altera um arquivo de aceite já em commit. Um hook nega a edição pelo agente, e a guarda do harness avisa no PR quando um aceite existente muda.
- **Cobertura:** a CI confere o ID na tag do teste, como descrito em "Aceite no pull request". O ID vai na tag, e não no título, porque os dois relatórios JSON, o do Vitest e o do Playwright, trazem as tags de cada teste. O `strictTags` do Vitest fica desligado nas configs da API e da web, porque quem valida o ID é o conferidor: um ID que não está em nenhum arquivo de aceite reprova.
- **Cobertura:** a CI confere o ID na tag do teste, como descrito em "Aceite no pull request". O ID vai na tag, e não no título, porque os dois relatórios JSON, o do Vitest e o do Playwright, trazem as tags de cada teste. O `strictTags` do Vitest fica desligado nas configs da API e da web, porque quem valida o ID é o conferidor: um ID que não está em nenhum arquivo de aceite reprova. Os testes em shell do harness também provam critério: como nomear o caso e onde fica o relatório está no cabeçalho de `scripts/test/sh-helpers.sh`.

## Convenções de Git

Expand Down
37 changes: 37 additions & 0 deletions docs/tickets/LUI-182/LUI-183/aceite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Aceite do LUI-183

Ticket de preparação: faz os testes em shell do harness virarem prova de critério. Os CA1 a CA3 são provados por testes de unidade em shell, e o CA4, pela leitura do cabeçalho do auxiliar dos testes em shell.

Os testes em shell rodam na máquina, porque usam o `git` e o `jq`, e os do próprio ticket são em shell também: eles estão no relatório novo com a tag `@LUI-183-CA<n>`, o que prova o mecanismo por dentro. Cada teste prepara uma suíte de mentira (um script que carrega o auxiliar e roda um ou dois casos com um ID de mentira, `PROJ-9`) e a roda pelo executor novo, com o relatório numa pasta de `apps/api/reports/`. O contêiner `api` monta o repositório, então a pasta é a mesma para o conferidor, que roda no contêiner (`scripts/node-in-api.sh`) sobre um arquivo de aceite de mentira gravado ao lado do relatório. Assim o conferidor é o de verdade, o mesmo do `full`, e o teste não depende de como o relatório foi escrito, só de ele ser lido.

O relatório novo é um arquivo à parte do dos testes em Node, no mesmo formato, e entra no conferidor como mais um `vitest:<arquivo>`. O ID continua só na tag: o token `@<ticket>-CA<n>` fica no nome do caso e o relatório o repete em `tags`, como o repórter dos testes em Node faz.

## LUI-183-CA1

**Marca:** teste de unidade

**Cenário:** Dado um arquivo de aceite com o critério `PROJ-9-CA1` de teste de unidade e uma suíte em shell com um caso que passa e traz `@PROJ-9-CA1` no nome, quando o executor roda a suíte, então o relatório gravado traz esse caso com a tag `@PROJ-9-CA1` e o estado `passed`, o título continua como foi escrito, e o conferidor, lendo o relatório como `vitest`, sai com 0 e diz que `PROJ-9-CA1` está coberto por 1 teste.

**Teste previsto:** testes dos scripts do harness, `scripts/test/shell-tests.test.sh`, "o caso com o ID no nome sai no relatório com a tag e o conferidor o conta como coberto".

## LUI-183-CA2

**Marca:** teste de unidade

**Cenário:** Dado o mesmo arquivo de aceite e uma suíte em shell cujo caso com `@PROJ-9-CA1` no nome falha, quando o executor roda a suíte, então ele sai com 1, o relatório traz o caso com a tag e o estado `failed`, e o conferidor sai com 1 dizendo que um teste com o ID `PROJ-9-CA1` falhou. Dado uma suíte que termina com erro sem ter registrado nenhum caso que falhou, o relatório traz um caso `failed` que diz isso, e o executor sai com 1.

**Teste previsto:** testes dos scripts do harness, `scripts/test/shell-tests.test.sh`, "o caso que falha sai como failed e o conferidor reprova o critério" e "a suíte que termina com erro sem caso que falhou vira um caso que falhou".

## LUI-183-CA3

**Marca:** teste de unidade

**Cenário:** Dado o relatório de uma execução em que o caso com `@PROJ-9-CA1` passou, quando o executor roda de novo no mesmo caminho de relatório com uma suíte que não tem esse caso, então o relatório novo não traz a tag, e o conferidor sai com 1 dizendo que `PROJ-9-CA1` não tem nenhum teste.

**Teste previsto:** testes dos scripts do harness, `scripts/test/shell-tests.test.sh`, "o relatório de uma execução anterior não cobre o critério cujo caso não rodou agora".

## LUI-183-CA4

**Marca:** documento

**Cenário:** Dado o cabeçalho do auxiliar dos testes em shell, quando se lê o diff do PR, então ele diz como nomear um teste de aceite (o token `@<ticket>-CA<n>` no nome do caso, como no teste em Node) e onde fica o relatório (o caminho dentro de `apps/api/reports/`, apagado e regravado a cada execução do executor).
9 changes: 4 additions & 5 deletions scripts/test/record-touched.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,9 @@ expect_touched() {
local got
got="$(touched "$2")"
if [[ "$got" == "$3" ]]; then
echo "ok: $1"
pass_case "$1"
else
echo "FALHOU: $1 (anotado: ${got:-nada}; esperava: ${3:-nada})"
failed=$((failed + 1))
fail_case "$1" "anotado: ${got:-nada}; esperava: ${3:-nada}"
fi
}

Expand All @@ -43,7 +42,7 @@ run() {
local code
"$HOOK" <<<"$1" >/dev/null 2>&1
code=$?
[[ $code -eq 0 ]] || { echo "FALHOU: o hook saiu com $code para $1"; failed=$((failed + 1)); }
[[ $code -eq 0 ]] || fail_case "o hook saiu com código diferente de 0" "saída $code para $1"
}

run "{\"session_id\":\"s1\",\"tool_input\":{\"file_path\":\"$project/apps/api/src/a.ts\"}}"
Expand Down Expand Up @@ -101,7 +100,7 @@ run_in() {
local code
CLAUDE_PROJECT_DIR="$1" "$HOOK" <<<"$2" >/dev/null 2>&1
code=$?
[[ $code -eq 0 ]] || { echo "FALHOU: o hook saiu com $code sem a pasta de estado"; failed=$((failed + 1)); }
[[ $code -eq 0 ]] || fail_case "o hook sem a pasta de estado saiu com código diferente de 0" "saída $code"
}
run_in "$project/nao-existe" "{\"session_id\":\"x1\",\"tool_input\":{\"file_path\":\"$project/apps/api/src/a.ts\"}}"
expect_touched "sem a raiz do projeto, o hook não anota e sai com 0" x1 ""
Expand Down
54 changes: 43 additions & 11 deletions scripts/test/sh-helpers.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,52 @@
# Auxiliares das suítes em shell do harness (a dos hooks e a da trava do modo completo): o contador de
# casos que falharam e a saída `ok:` ou `FALHOU:` de cada um. Cada suíte os carrega com `source`.
# Auxiliares das suítes em shell do harness (a dos hooks, a da trava do modo completo e as dos modos do
# verify.sh): o contador de casos que falharam e a saída `ok:` ou `FALHOU:` de cada um. Cada suíte os
# carrega com `source`.
#
# Um caso de shell também pode provar um critério de aceite, como um teste em Node. Para isso, ponha o
# token do ID no nome do caso, como `@<ticket>-CA<n>` (por exemplo, `@LUI-183-CA1`), e o critério tem de
# ser de teste no arquivo de aceite. O nome fica como foi escrito, e o executor
# (scripts/test/shell-tests.sh) repete o token em `tags` no relatório, que o conferidor do aceite lê.
#
# O relatório fica em apps/api/reports/harness/shell-tests.json, no mesmo formato do Vitest que o
# relatório dos testes em Node (node-tests.json, ao lado). O executor o apaga e o grava de novo a cada
# execução, então um caso que não rodou agora não cobre critério nenhum. Quem grava os casos nele são
# estes auxiliares, pelo `record_case`, no arquivo que o executor aponta em SH_TEST_LOG; sem a variável,
# eles só imprimem, como sempre. Uma suíte com asserção própria dá o veredito pelo `pass_case` e pelo
# `fail_case`, que imprimem, registram e contam o caso.

failed=0

# Registra um caso no relatório dos testes em shell, quando o executor o pediu (SH_TEST_LOG). A suíte é
# a que o executor está rodando (SH_TEST_SUITE), ou o nome do script, se a suíte roda sozinha.
# O log tem um caso por linha, separado por TAB: o TAB e a quebra de linha do nome viram espaço.
# record_case <passed|failed> <nome>
record_case() {
[[ -n "${SH_TEST_LOG:-}" ]] || return 0
local name="${2//[$'\t\n']/ }"
printf '%s\t%s\t%s\n' "${SH_TEST_SUITE:-${0##*/}}" "$1" "$name" >>"$SH_TEST_LOG"
}

# O veredito de um caso: imprime, registra no relatório e, se falhou, conta.
# pass_case <nome>
pass_case() {
echo "ok: $1"
record_case passed "$1"
}

# O detalhe só sai na tela: o relatório traz o nome, que é por onde o conferidor liga o caso ao critério.
# fail_case <nome> [detalhe]
fail_case() {
echo "FALHOU: $1${2:+ ($2)}"
record_case failed "$1"
failed=$((failed + 1))
}

# Um caso que passa quando a condição sai com 0.
# check <nome> <condição...>
check() {
local name="$1"
shift
if "$@"; then
echo "ok: $name"
else
echo "FALHOU: $name"
failed=$((failed + 1))
fi
if "$@"; then pass_case "$name"; else fail_case "$name"; fi
}

# Um caso de hook: roda o hook com a entrada dada e confere o código de saída e um trecho da saída de
Expand All @@ -24,10 +57,9 @@ expect_hook() {
err="$("$hook" <<<"$input" 2>&1 >/dev/null)"
got=$?
if [[ $got -ne $want || "$err" != *"$text"* ]]; then
echo "FALHOU: $name (saída $got, esperava $want; erro: $err)"
failed=$((failed + 1))
fail_case "$name" "saída $got, esperava $want; erro: $err"
else
echo "ok: $name"
pass_case "$name"
fi
}

Expand Down
93 changes: 93 additions & 0 deletions scripts/test/shell-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
#
# Executor das suítes em shell do harness: roda cada suíte e grava o relatório no formato do Vitest,
# o mesmo que o relatório dos testes em Node, para o `check-report.cjs` e o conferidor do aceite (o
# scripts/check-acceptance.cjs) o lerem. O token `@<ticket>-CA<n>` do nome de um caso vira também uma
# tag dele, como no repórter dos testes em Node (scripts/test/node-test-report.cjs). Como nomear um
# caso de aceite está no cabeçalho do scripts/test/sh-helpers.sh.
#
# scripts/test/shell-tests.sh <relatório> <suíte>...
#
# Apaga o relatório antes de rodar e sempre grava um novo no fim, até com uma suíte que falha: um
# relatório de uma execução anterior nunca cobre um critério cujo caso não rodou agora. Segue depois
# de uma suíte que falha, para o relatório trazer todas. Uma suíte que sai com erro sem ter registrado
# um caso que falhou (ou que não existe) vira um caso que falhou, com o código dela, para o erro dela
# não passar por um relatório sem falha.
#
# Sai com 0 se todas as suítes passaram, 1 se alguma falhou e 2 no uso errado. Roda na máquina, como
# as suítes, e precisa do `jq`.

set -uo pipefail

if [[ $# -lt 2 ]]; then
echo 'Uso: scripts/test/shell-tests.sh <relatório> <suíte>...' >&2
exit 2
fi
command -v jq >/dev/null || {
echo 'Falta o `jq` na máquina: o executor dos testes em shell precisa dele.' >&2
exit 2
}

report="$1"
shift
suites=("$@")

log="$(mktemp)" || exit 2
trap 'rm -f "$log"' EXIT
rm -f "$report"
mkdir -p "$(dirname "$report")" || exit 2

# Se a suíte registrou algum caso que falhou.
# has_failed_case <suíte>
has_failed_case() {
awk -F'\t' -v suite="$1" '$1 == suite && $2 == "failed" { found = 1 } END { exit !found }' "$log"
}

status=0
export SH_TEST_LOG="$log"
for suite in "${suites[@]}"; do
export SH_TEST_SUITE="$suite"
if [[ -f "$suite" ]]; then
bash "$suite"
code=$?
else
echo "A suíte $suite não existe."
code=127
fi
if [[ $code -ne 0 ]]; then
status=1
# O erro de uma suíte sem um caso que o explique vira um caso que falhou.
has_failed_case "$suite" ||
printf '%s\tfailed\t%s\n' "$suite" "a suíte terminou com erro (saída $code) sem registrar um caso que falhou" >>"$log"
fi
done
unset SH_TEST_LOG SH_TEST_SUITE

# O relatório: um arquivo por suíte, os casos na ordem em que rodaram. O `-R` lê o log linha a linha,
# e o `scan` tira o token do ID do nome (sem grupos, ele devolve o texto inteiro do que casou). A
# expressão do token é a do TOKEN de scripts/test/node-test-report.cjs: mude as duas juntas.
jq -R -n --args '
[inputs | split("\t") | {suite: .[0], status: .[1], title: .[2]}] as $cases
| [$ARGS.positional[] as $suite
| {name: $suite,
assertionResults: [$cases[] | select(.suite == $suite) | {
title: .title,
fullName: .title,
ancestorTitles: [],
tags: [.title | scan("@[A-Z][A-Z0-9]*-[0-9]+-CA[0-9]+")],
status: .status
}]}] as $files
| [$files[].assertionResults[]] as $all
| {
numTotalTests: ($all | length),
numFailedTests: ($all | map(select(.status == "failed")) | length),
numPendingTests: 0,
numTodoTests: 0,
testResults: $files
}' "${suites[@]}" <"$log" >"$report" || {
echo "Não deu para gravar o relatório $report." >&2
rm -f "$report"
exit 2
}

exit $status
144 changes: 144 additions & 0 deletions scripts/test/shell-tests.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
#
# Testes do executor das suítes em shell (scripts/test/shell-tests.sh) e do relatório dele: o caso com o
# token de um ID no nome sai com a tag, o conferidor do aceite o conta, e o relatório é refeito a cada
# execução. Cada teste monta suítes de mentira (scripts que carregam o sh-helpers.sh e rodam um caso com
# o ID de mentira `PROJ-9`), roda o executor e confere o relatório com o conferidor de verdade, o mesmo
# do modo completo, pelo scripts/node-in-api.sh. O conferidor roda no contêiner `api`, que monta o
# repositório em /repo, então a pasta de trabalho fica em apps/api/reports/ (ignorada pelo Git), e o
# conferidor lê dali o aceite de mentira e o relatório. Rodam na máquina, como o executor, que precisa
# do `jq`. Quem chama é o scripts/verify.sh.

set -uo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# shellcheck source=sh-helpers.sh
source "$ROOT/scripts/test/sh-helpers.sh"

command -v jq >/dev/null || { echo "Falta o \`jq\` na máquina: o executor e o teste dele precisam dele."; exit 2; }

RUNNER="$ROOT/scripts/test/shell-tests.sh"
mkdir -p "$ROOT/apps/api/reports"
work="$(mktemp -d -p "$ROOT/apps/api/reports" shell-tests.XXXXXX)"
trap 'rm -rf "$work"' EXIT
# A pasta de trabalho como o contêiner a vê, e relativa à raiz dele, que é onde o conferidor lê os tickets.
in_container="/repo/${work#"$ROOT"/}"
tickets="${work#"$ROOT"/}/tickets"
report="$work/shell-tests.json"

# O arquivo de aceite de mentira: um critério de teste de unidade, PROJ-9-CA1.
mkdir -p "$work/tickets/PROJ-8/PROJ-9"
cat >"$work/tickets/PROJ-8/PROJ-9/aceite.md" <<'ACCEPTANCE'
# Aceite do PROJ-9

## PROJ-9-CA1

**Marca:** teste de unidade

**Cenário:** Dado um caso de mentira, quando ele roda, então o critério está coberto.
ACCEPTANCE

# suite <arquivo> <corpo>: uma suíte de mentira, que carrega os auxiliares reais.
suite() {
{
printf '#!/usr/bin/env bash\nsource "%s/scripts/test/sh-helpers.sh"\n' "$ROOT"
printf '%s\n' "$2"
} >"$work/$1"
}

suite passes.sh 'check "o caso passa @PROJ-9-CA1" true
finish "da suíte de mentira"'
suite fails.sh 'check "o caso falha @PROJ-9-CA1" false
finish "da suíte de mentira"'
suite without-case.sh 'check "um caso sem o ID" true
finish "da suíte de mentira"'
# Termina com erro sem ter registrado um caso que falhou.
suite crashes.sh 'exit 3'

# run <suíte...>: roda o executor sobre as suítes dadas, no relatório de mentira, e deixa o código em $code.
run() {
local suites=() name
for name in "$@"; do suites+=("$work/$name"); done
"$RUNNER" "$report" "${suites[@]}" >/dev/null 2>&1
code=$?
}

# checker: o conferidor do aceite sobre o relatório de mentira, lido como `vitest`, com a saída em $out e
# o código em $checker_code.
checker() {
out="$(scripts/node-in-api.sh scripts/check-acceptance.cjs --optional - "$tickets" "vitest:$in_container/shell-tests.json" 2>&1 </dev/null)"
checker_code=$?
}

# O relatório de mentira satisfaz o filtro do jq.
report_has() {
jq -e "$1" "$report" >/dev/null
}

cd "$ROOT" || exit 2

# --- CA1: o caso com o ID no nome sai com a tag, e o conferidor o conta ---
ca1() {
run passes.sh &&
[[ $code -eq 0 ]] &&
report_has '.testResults[0].assertionResults[0]
| .title == "o caso passa @PROJ-9-CA1" and .tags == ["@PROJ-9-CA1"] and .status == "passed"' &&
report_has '.numTotalTests == 1 and .numFailedTests == 0' &&
checker &&
[[ $checker_code -eq 0 && "$out" == *"PROJ-9-CA1: coberto por 1 teste(s)."* ]]
}
check "o caso com o ID no nome sai no relatório com a tag e o conferidor o conta como coberto @LUI-183-CA1" ca1

# --- CA2: o caso que falha reprova o critério ---
ca2_failed() {
run fails.sh &&
[[ $code -eq 1 ]] &&
report_has '.testResults[0].assertionResults[0] | .tags == ["@PROJ-9-CA1"] and .status == "failed"' &&
checker &&
[[ $checker_code -eq 1 && "$out" == *"PROJ-9-CA1: um teste com o ID dele falhou."* ]]
}
check "o caso que falha sai como failed e o conferidor reprova o critério @LUI-183-CA2" ca2_failed

ca2_crash() {
run crashes.sh &&
[[ $code -eq 1 ]] &&
report_has '.numTotalTests == 1 and .numFailedTests == 1
and (.testResults[0].assertionResults[0] | .status == "failed" and (.title | contains("terminou com erro (saída 3)")))'
}
check "a suíte que termina com erro sem caso que falhou vira um caso que falhou @LUI-183-CA2" ca2_crash

# --- CA3: o relatório de uma execução anterior não cobre o critério ---
ca3() {
# Antes, o relatório traz a tag: sem isso, o teste não provaria que foi a execução nova que a tirou.
run passes.sh && report_has '.testResults[0].assertionResults[0].tags == ["@PROJ-9-CA1"]' &&
run without-case.sh &&
[[ $code -eq 0 ]] &&
report_has '[.testResults[].assertionResults[].tags[]] == []' &&
checker &&
[[ $checker_code -eq 1 && "$out" == *"PROJ-9-CA1"*"não tem nenhum teste"* ]]
}
check "o relatório de uma execução anterior não cobre o critério cujo caso não rodou agora @LUI-183-CA3" ca3

# --- O que o executor garante além dos critérios ---
missing_suite() {
run passes.sh nao-existe.sh &&
[[ $code -eq 1 ]] &&
report_has '.numFailedTests == 1 and (.testResults[1].assertionResults[0].title | contains("saída 127"))'
}
check "a suíte que não existe vira um caso que falhou, e as outras entram no relatório" missing_suite

after_failure() {
run fails.sh passes.sh &&
[[ $code -eq 1 ]] &&
report_has '.testResults | length == 2 and (.[1].assertionResults[0].status == "passed")'
}
check "o executor segue depois da suíte que falha e o relatório traz todas" after_failure

check "sem suíte, o executor recusa com a saída 2" bash -c '"$0" "$1" 2>/dev/null; [[ $? -eq 2 ]]' "$RUNNER" "$report"

# Sem o executor (sem SH_TEST_LOG), os auxiliares só imprimem, como sempre.
no_executor() {
(unset SH_TEST_LOG SH_TEST_SUITE && bash "$work/passes.sh" | grep -q '^ok: o caso passa')
}
check "sem o executor, os auxiliares só imprimem" no_executor

finish "do executor dos testes em shell"
Loading
Loading