fix(e2e): o trace nunca era gravado, e o instrumento não sabia dizer o que viu

O caso (4) reprovou com "a recusa apagou o logo — a gravação não foi atômica".
Quatro lentes independentes investigaram, cada uma com um cético tentando
derrubá-la. Resultado: **nada foi apagado**, e a acusação é impossível por
construção — a recusa por bytes sai da rota com 415 (`route.ts:399-406`) treze
linhas antes da primeira leitura do banco e vinte antes do storage.

O que aconteceu de fato, por eliminação, cada elo medido:

  a. os DOIS `logo_path` seguiam gravados ~6s depois — o `afterAll` clicou
     "Remover" nas duas camadas e recebeu 200 nas duas, e o botão só existe
     quando a camada TEM logo;
  b. `marcaDaInstalacao()` nunca degradou (zero avisos no log, sonda viva);
  c. `collapsed` era `false` (cookie inexistente em contexto novo);
  d. com `logo` truthy a casca SEMPRE desenha `<img>` (`Sidebar.tsx:65,75-76`);
  e. não houve exceção do servidor naquela janela.

Logo: **o DOM medido não era a casca do app.** Foi redirect ou troca de casca.
Qual delas — NÃO MEDIDO, e é exatamente o que este commit passa a permitir medir.

## O que muda

**1. `playwright.config.ts`: o trace nunca era gravado.** `trace: "on-first-retry"`
com `retries: 0`. As duas linhas estão certas isoladamente e erradas juntas: uma
diz "só no retry", a outra diz "não há retry". Por isso o único artefato do run
era um `error-context.md` que fotografou a página do `afterAll`, e não a que
falhou — e responder "qual era a URL?" custou quatro agentes em vez de um clique.
Agora `retain-on-failure` + screenshot: custa disco só quando já se está pagando
o custo maior, que é ter um vermelho.

**2. `logoDaBarra` para de conflar.** Ela devolvia o MESMO `null` para três
estados — "a barra está sem `<img>`", "não há barra nenhuma" (redirect de auth,
onboarding, suspensão, 403, gate de MFA) e "medi cedo demais" — enquanto a
asserção acusava um quarto. Agora prova a URL e a casca antes de medir, e diz
qual dos três aconteceu. **Nenhuma asserção ficou mais frouxa**: o caso continua
vermelho se o logo sumir de verdade.

**3. A precondição do caso (4) era tautológica.** Ela exigia só `não-nulo`, e
`Sidebar` faz `activeOrg?.marca?.logoUrl || brand.logoUrl` — se a camada da
organização não resolvesse, a barra cairia no logo da INSTALAÇÃO e a precondição
passaria igual, deixando a comparação final comparar o logo da instalação
consigo mesmo. Agora exige o prefixo da própria organização.

## E a correção de um erro meu, pela segunda vez no mesmo ponto

O comentário deste workflow afirmava que `marca-logo.spec.ts` era "a ÚLTIMA da
PARTE_2 de propósito". **É falso: o Playwright ordena por CAMINHO, não pela ordem
da linha de comando.** Medido: escrita por último, executou em 15º de 23 — a
linha de progresso do run mostra 14 testes completando depois dela:

    ···F°°··············

Consequência: eu havia creditado à reordenação o desaparecimento do React #418
entre dois ciclos. Não foi ela — a ordem nunca mudou. Foi o `test.afterAll`, que
passou a limpar as duas camadas.

O erro instrutivo aqui não é o proxy ruim que usei para contar logins. É que
depois de ser corrigido eu **re-medi a mesma grandeza com mais cuidado**, e
nunca perguntei se aquela grandeza tinha efeito no mundo. Refinei a medição de
algo que não fazia nada.

Amostra n=2, com resultados opostos: no run anterior (`b804e207`) o caso (4)
PASSOU e reprovou o (5); entre os dois SHAs há um commit só de documentação.
Mesmo binário, vermelho diferente. Chamar de "flake" ou de "determinístico"
agora seria afirmar sem medir — falta o trace, que este commit passa a gravar.

Suspeito nº 1 para a próxima rodada, medido como código e não como causa:
`lib/auth/server.ts:34-38` DESCARTA o erro de `getUser()`, então falha
transitória vira "não está logado" → redirect silencioso para `/login` — que põe
o logo num `<div>`, não num `<aside>`. A mesma função trata isso corretamente 60
linhas abaixo, com o comentário "degradar permissão em silêncio é o pior
desfecho possível".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB
This commit is contained in:
Rafael Melgaço
2026-08-14 18:45:05 -03:00
co-authored by Claude Opus 5
parent 07b1ea6613
commit 40dfca1cd6
4 changed files with 175 additions and 12 deletions
+26 -7
View File
@@ -113,11 +113,24 @@ jobs:
agente-papeis-operador.spec.ts prova-painel-provedores.spec.ts
followup-linguagem.spec.ts followup-ramos.spec.ts
wizard-do-funcionario.spec.ts mfa-opcional.spec.ts
# `marca-logo.spec.ts` é a ÚLTIMA da PARTE_2 de propósito. As duas partes
# rodam como passos do MESMO job, contra o MESMO banco, sem reset entre
# elas — o que uma spec deixa gravado, as seguintes enxergam. Esta sobe
# logo de INSTALAÇÃO, que aparece em toda tela pública. Se a restauração
# dela falhar, ser a última limita o alcance a zero specs em vez de 23.
# ⚠️ A ORDEM DESTA LISTA NÃO DECIDE A ORDEM DE EXECUÇÃO. O Playwright
# ordena os arquivos por CAMINHO, não pela ordem em que são passados na
# linha de comando. Medido no run 31838253496: `marca-logo.spec.ts` estava
# escrita por último aqui e executou em 15º de 23 — a linha de progresso
# `···F°°··············` mostra 14 testes completando DEPOIS dela.
#
# Este comentário afirmava, por três commits, que ela era "a ÚLTIMA de
# propósito" e que isso limitava contaminação a zero specs. Era falso, e
# a consequência foi pior que o comentário errado: creditei a esse
# reposicionamento o desaparecimento do React #418 entre dois ciclos.
# Não foi ele — a ordem nunca mudou. Foi o `test.afterAll` da própria
# spec, que passou a limpar as DUAS camadas de marca e por isso as specs
# seguintes deixaram de ver logo gravado.
#
# O que continua verdade: as duas partes rodam como passos do MESMO job,
# contra o MESMO banco, sem reset entre elas — o que uma spec deixa
# gravado, as seguintes enxergam. Quem protege contra isso é a limpeza da
# spec, não a posição dela nesta lista.
#
# ⚠️ Ela esteve no TOPO desta lista por três commits, pela razão errada.
# Eu media "carga de login por parte" com um regex que contava a PALAVRA
@@ -128,8 +141,14 @@ jobs:
# começo — o `AUTH_RATE_LIMIT_LOGIN_IP: "1000"` mais abaixo desliga o
# teto no CI, e esta spec faz 6 logins (eram 3 até a revisão que descobriu
# que dois casos rodavam DESLOGADOS: `page` é fixture de escopo de teste,
# não de worker — cada caso precisa do seu login). Quem decide a posição é
# a contaminação, não a contagem.
# não de worker — cada caso precisa do seu login).
#
# E o desfecho: a movida não teve efeito NENHUM, porque a ordem é por
# caminho (ver o bloco acima). Duas medições erradas em sequência levaram
# a uma mudança inócua — que eu ainda por cima creditei por um resultado
# que veio de outra coisa. Fica escrito porque o erro instrutivo não é o
# proxy ruim: é ter medido de novo, com outro instrumento ruim, em vez de
# perguntar se o mecanismo suposto existia.
#
# (Este comentário mora FORA do bloco `>-` porque dentro dele `#` não é
# comentário: é conteúdo, e cada palavra viraria argumento do playwright.
+73
View File
@@ -1335,3 +1335,76 @@ spec exercitar tela de `/app` com logo de instalação no banco.
Isso é dívida declarada, não pendência esquecida: o caso que faltaria é
"navegar em `/app/ai/*` depois de subir logo da instalação e conferir o console".
### Retratação nº 2 sobre o #418 — e agora com o mecanismo medido
Registrei acima que o React #418 sumiu "por causa da reordenação, não do
conserto". **Também estava errado.** A reordenação nunca teve efeito nenhum:
> **O Playwright ordena os arquivos por CAMINHO, não pela ordem em que são
> passados na linha de comando.**
Medido no run 31838253496: `marca-logo.spec.ts` estava escrita por ÚLTIMO na
`SPECS_PARTE_2` e executou em **15º de 23**. A linha de progresso do próprio
run mostra 14 testes completando depois dela:
···F°°··············
Então o que eliminou o #418 foi o **`test.afterAll`** que a revisão acrescentou:
ele limpa as DUAS camadas de marca, e as 14 specs seguintes deixaram de ver logo
gravado. O conserto de hidratação continua sem prova de comportamento — isso não
muda —, mas o crédito agora tem dono certo.
**O erro que se repete aqui não é o proxy ruim.** Foi:
1. medir com um regex que contava a palavra "login" → número errado;
2. ser corrigido, re-medir com outro instrumento → número certo;
3. mover a spec com base nele **sem nunca perguntar se mover a spec faz alguma
coisa**.
Os passos 1 e 2 são sobre precisão. O passo 3 é a falha real: eu refinei a
medição de uma grandeza que não tinha efeito no mundo. O comentário no
`e2e.yml` afirmava "é a última de propósito" — uma frase sobre o comportamento
do Playwright que eu nunca medi, escrita com a mesma confiança das que eu havia
medido.
### O caso (4): nada foi apagado, e o instrumento não sabia dizer o que viu
O vermelho dizia "a recusa apagou o logo — a gravação não foi atômica". É
**impossível por construção**: a recusa por bytes sai da rota com 415 em
`route.ts:399-406`, treze linhas antes da primeira leitura do banco e vinte
antes do primeiro toque no storage.
O que de fato aconteceu, por cadeia de eliminação — cada elo medido:
| # | Fato | Como se sabe |
|---|---|---|
| a | os DOIS `logo_path` seguiam gravados ~6s depois | o `afterAll` clicou "Remover" nas duas camadas e recebeu 200 nas duas; o botão só existe quando a camada TEM logo |
| b | `marcaDaInstalacao()` nunca degradou | zero ocorrências do aviso no log do run, e a sonda está viva |
| c | `collapsed` era `false` | cookie inexistente em contexto novo |
| d | com `logo` truthy a casca SEMPRE desenha `<img>` | `Sidebar.tsx:65,75-76` |
| e | não houve exceção do servidor na janela | os dois `⨯` do log são de outros instantes |
De (a)+(b)+(c)+(d): qualquer render de `/app` teria produzido `aside img`. Logo
**o DOM medido não era a casca do app** — foi redirect ou troca de casca. Qual
delas, **NÃO MEDIDO**.
E não dava para medir, por um detalhe de configuração que vale mais que este
caso: `playwright.config.ts` tinha `trace: "on-first-retry"` com `retries: 0`.
As duas linhas estão certas isoladamente e, juntas, significam **trace nunca
gravado**. O único artefato do run era um `error-context.md` que fotografou a
página do `afterAll`, não a que falhou.
**Suspeito nº 1, não medido e fora do escopo:** `lib/auth/server.ts:34-38`
descarta o erro de `supabase.auth.getUser()`. Qualquer falha transitória contra
o GoTrue vira "não está logado" → `redirect("/login")` em silêncio — e `/login`
põe o logo num `<div>`, não num `<aside>`. A MESMA função trata isso corretamente
sessenta linhas abaixo, com o comentário "degradar permissão em silêncio é o
pior desfecho possível". É a doutrina "falhar fechado na ação, **aberto na
informação**" ferida no primeiro dos três pontos.
**Amostra n=2, com resultados opostos:** no run anterior (`b804e207`) o caso (4)
PASSOU e quem reprovou foi o (5); entre os dois SHAs há um único commit, e ele
só toca documentação. Mesmo binário, vermelho diferente. Chamar de
"determinístico" ou de "flake" com essa amostra seria afirmar sem medir — o que
falta é o trace, e é o que este commit passa a produzir.
+15 -1
View File
@@ -113,7 +113,21 @@ export default defineConfig({
retries: 0,
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
// ⚠️ Era `on-first-retry`, e com `retries: 0` logo acima isso significa
// **trace nunca gravado**. As duas linhas estão certas isoladamente e
// erradas juntas: uma diz "só no retry", a outra diz "não há retry".
//
// O preço apareceu inteiro numa investigação real: um vermelho em
// `marca-logo.spec.ts` afirmava "a recusa apagou o logo", e responder o que
// de fato aconteceu com o DOM custou quatro agentes e uma cadeia de
// eliminação — porque o único artefato do run era um `error-context.md`
// que fotografou a página do `afterAll`, não a que falhou. Um trace teria
// dado URL, DOM e rede daquele instante, sem hipótese nenhuma.
//
// `retain-on-failure` grava sempre e descarta no verde: custa disco só
// quando já se está pagando o custo maior, que é ter um vermelho.
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
// Produção (`next build` antes!): dev-server compila por rota (40-80s) e
+61 -4
View File
@@ -247,9 +247,43 @@ async function medirImagem(img: Locator): Promise<LogoNaTela> {
}));
}
/** O logo da barra lateral. `null` = a barra está sem `<img>` (nome em texto). */
/**
* O logo da barra lateral. `null` = a barra está sem `<img>` (nome em texto).
*
* ⚠️ Esta função devolvia `null` para TRÊS estados diferentes, e a asserção que a
* consome culpava um QUARTO. Um vermelho real disse "a recusa apagou o logo — a
* gravação não foi atômica", e a investigação provou que nada tinha sido apagado:
* o `afterAll`, seis segundos depois, clicou "Remover" nas DUAS camadas e recebeu
* 200 nas duas — o que só acontece com os dois `logo_path` ainda no banco.
*
* Os três estados que viravam o mesmo `null`:
* 1. a barra existe e está sem `<img>` — o que a asserção quer medir;
* 2. **não há barra nenhuma** — a página redirecionou (`/login`, `/onboarding`,
* `/account-suspended`, `/403`) ou o `MfaEnrollGate` trocou a casca inteira;
* 3. mediu antes de a casca montar.
*
* O (2) é o provável, porque para o (1) acontecer as DUAS camadas de marca teriam
* de estar vazias — e a da instalação não foi tocada pelo caso que falhou.
*
* Agora a casca é provada ANTES: `toHaveURL(/\/app\//)` separa "redirecionou" de
* "a barra perdeu o logo" a custo zero, e a espera pelo `<aside>` separa o (3).
* Nenhuma asserção ficou mais frouxa: o caso continua vermelho se o logo sumir
* de verdade — só passa a dizer QUAL das coisas aconteceu.
*/
async function logoDaBarra(page: Page): Promise<LogoNaTela | null> {
const img = page.locator("aside img").first();
await expect(
page,
`saiu de /app — ${page.url()}. A barra não sumiu: a PÁGINA é outra ` +
`(redirect de auth, onboarding, suspensão, 403 ou o gate de MFA).`,
).toHaveURL(/\/app(\/|$)/, { timeout: 15_000 });
const casca = page.locator("aside").first();
await expect(
casca,
`a casca do app não montou em /app — ${page.url()}. Sem <aside> não há o que medir.`,
).toBeAttached({ timeout: 15_000 });
const img = casca.locator("img").first();
if ((await img.count()) === 0) return null;
return medirImagem(img);
}
@@ -413,6 +447,18 @@ test.describe("o logo subido pela tela chega à tela", () => {
// porque `antes!.src` lá embaixo, com `antes` nulo, reprova como
// "Cannot read properties of null" — que não diz a ninguém o que faltou.
expect(antes, "precondição: a barra precisa entrar neste caso COM logo da empresa").not.toBeNull();
// E COM O DA EMPRESA, não com o da instalação. `Sidebar.tsx` faz
// `activeOrg?.marca?.logoUrl || brand.logoUrl`: se a camada da organização
// não resolvesse, a barra cairia no logo da INSTALAÇÃO e este `not.toBeNull()`
// ficaria verde do mesmo jeito — e a comparação lá embaixo (`depois === antes`)
// viraria tautologia, comparando o logo da instalação consigo mesmo. O caso
// se chama "o logo da empresa sobrevive à recusa"; então é o da empresa que
// a precondição tem de provar.
expect(
antes!.src,
"precondição: a barra entrou com o logo da INSTALAÇÃO, não o da empresa — " +
"o caso (3) não deixou o estado que este caso pressupõe",
).toContain(`${PREFIXO_PUBLICO}${creds.org_id}/`);
await page.goto("/app/settings/marca");
await subir(page, "organizacao", {
@@ -440,8 +486,19 @@ test.describe("o logo subido pela tela chega à tela", () => {
await page.goto("/app");
const depois = await logoDaBarra(page);
expect(depois, "a recusa apagou o logo — a gravação não foi atômica").not.toBeNull();
expect(depois!.src, "a recusa mudou o logo — a gravação não foi atômica").toBe(antes!.src);
// ⚠️ As mensagens NÃO acusam mais a gravação. A versão anterior dizia "a
// recusa apagou o logo — a gravação não foi atômica", e isso é impossível
// por construção: a recusa por bytes sai da rota com 415 em
// `route.ts:399-406`, treze linhas ANTES da primeira leitura do banco
// (`:419`) e vinte antes do primeiro toque no storage. Uma mensagem que
// nomeia uma causa impossível manda o próximo leitor investigar o lugar
// errado — foi o que aconteceu, e custou caro.
expect(
depois,
`a barra ficou sem logo depois de uma recusa (a recusa não escreve nada — ` +
`se isto reprovar, o logo sumiu por outro caminho)`,
).not.toBeNull();
expect(depois!.src, "a recusa trocou o logo por outro").toBe(antes!.src);
});
test("(5) remover devolve o logo da camada de baixo", async ({ page }) => {