Files
DeskcommCRM/playwright.config.ts
T
Rafael MelgaçoandClaude Opus 5 40dfca1cd6 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
2026-08-14 18:45:05 -03:00

173 lines
8.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { defineConfig } from "@playwright/test";
/**
* Lê o `.env.e2e` — o ambiente LOCAL da suíte.
*
* Falha ALTO se o arquivo não existir, em vez de deixar o app cair no
* `.env.local`: o modo de falha silencioso aqui é a suíte rodar contra o banco
* de PRODUÇÃO, que foi exatamente o que acontecia antes deste arquivo existir
* (medido em 2026-08-06).
*/
function envDoE2E(): Record<string, string> {
let bruto: string;
try {
bruto = readFileSync(".env.e2e", "utf8");
} catch {
throw new Error(
"Falta o .env.e2e — rode `pnpm e2e:env` (precisa do Supabase local de pé).\n" +
"Sem ele o app sob teste carregaria o .env.local, que aponta para PRODUÇÃO.",
);
}
const env: Record<string, string> = {};
for (const linha of bruto.split("\n")) {
const limpa = linha.trim();
if (limpa === "" || limpa.startsWith("#")) continue;
const i = limpa.indexOf("=");
if (i <= 0) continue;
env[limpa.slice(0, i)] = limpa.slice(i + 1);
}
const url = env.NEXT_PUBLIC_SUPABASE_URL ?? "";
// Um `.env.e2e` apontando para fora do localhost é pior que nenhum, porque
// parece seguro.
if (!url.startsWith("http://127.0.0.1") && !url.startsWith("http://localhost")) {
throw new Error(`.env.e2e aponta para um Supabase que não é local (${url}) — recusado.`);
}
return env;
}
/**
* Publica o `.env.e2e` no ambiente do PROCESSO DE TESTE, não só do servidor.
*
* ## O defeito, medido em 2026-08-08 num worktree limpo
*
* `pnpm e2e:build && pnpm test:e2e` — o caminho documentado — morria antes do
* primeiro teste:
*
* Error: Sem credenciais do Supabase: defina NEXT_PUBLIC_SUPABASE_URL e
* SUPABASE_SERVICE_ROLE_KEY no ambiente (…) ou no .env.local
* at credenciaisSupabaseDeTeste (scripts/lib/env-de-teste.ts)
* at scripts/seed-e2e-credentials.ts
*
* A cadeia: as specs semeiam a própria precondição com `execFileSync`, o filho
* herda o ambiente do RUNNER, e o runner não recebia nada — `envDoE2E()`
* alimentava apenas `webServer.env`. `env-de-teste.ts` cai no `.env.local` como
* plano B, e num worktree limpo esse arquivo **não existe de propósito**: a
* ausência dele é o que impede a suíte de escrever em produção.
*
* Ou seja as duas proteções se anulavam: a que tira o `.env.local` do disco e a
* que injeta o ambiente só no servidor deixavam o seed sem nenhuma das duas
* fontes. O CI não notava porque contorna por dois caminhos — publica o arquivo
* no `$GITHUB_ENV` e ainda faz `cp .env.e2e .env.local`, recriando justamente o
* arquivo cuja ausência é a proteção.
*
* ## Por que aqui
*
* Este é o único ponto que já lê e valida o arquivo (inclusive recusando um
* `.env.e2e` que aponte para fora do localhost). Resolver no `package.json` com
* `set -a; . ./.env.e2e` funcionaria para quem usa o script e não para quem
* chama `playwright test` direto — e é o caminho que a mensagem de erro sugere,
* o que já prova que alguém teve de descobrir isto na mão.
*
* `process.env` VENCE quando a chave já existe: é a mesma precedência de
* `scripts/lib/env-de-teste.ts`, e é o que mantém de pé o
* `AUTH_RATE_LIMIT_LOGIN_IP` que o workflow define por fora. Sobrescrever aqui
* criaria a colisão descrita no comentário do `webServer` abaixo — servidor e
* processo de teste resolvendo a mesma chave para valores diferentes, que foi
* como `INTERNAL_SECRET` derrubou 8 specs com 401.
*/
function publicarNoProcesso(env: Record<string, string>): Record<string, string> {
for (const [chave, valor] of Object.entries(env)) {
if (process.env[chave] === undefined) process.env[chave] = valor;
}
return env;
}
// Porta do dev server sob teste. Default 3001; sobrescreva com E2E_PORT quando
// a 3001 já estiver ocupada por outro checkout/worktree.
const PORT = process.env.E2E_PORT ?? "3001";
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
fullyParallel: false,
/**
* UM worker. `fullyParallel: false` serializa apenas DENTRO de cada arquivo —
* entre arquivos o Playwright continua abrindo vários workers, e estes specs
* compartilham a MESMA organização, os MESMOS usuários e o MESMO banco: um
* spec revoga acesso enquanto outro checa escopo, um cria convite enquanto
* outro conta membros.
*
* Medido em 2026-07-31: rodando a suíte inteira, 10 a 15 specs falhavam com
* `waitForURL` estourando depois do login e elementos "not found"; os MESMOS
* specs, rodados isolados, passavam em 18s. Não era lógica nem lentidão: era
* interferência.
*
* O custo é wall-clock no CI. O benefício é que um vermelho volta a significar
* "quebrou" em vez de "deu azar na ordem" — e suíte que falha por azar ninguém
* lê, o que na prática desliga o gate inteiro.
*/
workers: 1,
retries: 0,
use: {
baseURL: BASE_URL,
// ⚠️ 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
// Turbopack dev quebra cookies() fora do request scope — inviável p/ e2e.
command: `pnpm exec next start --port ${PORT}`,
// O ambiente do servidor sob teste vem do `.env.e2e`, INJETADO aqui — e não
// do `.env.local`, que num checkout de trabalho aponta para PRODUÇÃO.
// Variável de ambiente real tem precedência sobre os arquivos `.env*` que o
// Next carrega sozinho, e é isto que impede a suíte de escrever no banco
// real (medido em 2026-08-06: sem esta injeção, ela escrevia).
//
// ⚠️ Isto cobre o SERVIDOR. Os scripts de seed que as specs chamam sozinhas
// liam `.env.local` direto do disco e escapavam daqui — o conserto do outro
// lado é `scripts/lib/env-de-teste.ts`, que faz `process.env` vencer. E é o
// `publicarNoProcesso` acima que garante que o `process.env` do runner tenha
// o que aquele conserto precisa: sem ele, num worktree sem `.env.local`, o
// seed não tinha NENHUMA das duas fontes.
env: publicarNoProcesso(envDoE2E()),
url: BASE_URL,
// false: reusar um server que já ocupa a porta pode ser OUTRO processo
// (ex.: bundle do Remotion na 3000) — o teste precisa do NOSSO next start.
reuseExistingServer: false,
// Sobre a precedência de `env`, MEDIDO (Playwright 1.5x, 2026-08-07) com um
// webServer que imprime o que recebeu:
//
// var só no process.env → CHEGA ao servidor (mescla, não substitui)
// var só no `env:` do config → chega
// var nos DOIS, valores dif. → vence a do `env:` do config
//
// A primeira linha é o que mantém `AUTH_RATE_LIMIT_LOGIN_IP` funcionando:
// ele é definido no passo do workflow e quem aplica o teto é o SERVIDOR.
//
// A terceira é a armadilha. Uma chave que exista no `.env.e2e` E no ambiente
// do CI silenciosamente resolve para valores DIFERENTES nos dois lados —
// servidor com um, processo de teste com outro. Foi assim que
// `INTERNAL_SECRET` derrubou 8 specs com 401. Por isso o workflow publica o
// `.env.e2e` inteiro no ambiente do job em vez de redigitar valores: uma
// fonte não colide consigo mesma.
timeout: 120_000,
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
});