diff --git a/evidence/ia-360-w1/w1-nova-01-tela-de-criar.png b/evidence/ia-360-w1/w1-nova-01-tela-de-criar.png index 497074cc6..9ab220c8d 100644 Binary files a/evidence/ia-360-w1/w1-nova-01-tela-de-criar.png and b/evidence/ia-360-w1/w1-nova-01-tela-de-criar.png differ diff --git a/evidence/ia-360-w1/w1-nova-02-preenchido.png b/evidence/ia-360-w1/w1-nova-02-preenchido.png index c27bc00e7..ba0a2ce11 100644 Binary files a/evidence/ia-360-w1/w1-nova-02-preenchido.png and b/evidence/ia-360-w1/w1-nova-02-preenchido.png differ diff --git a/evidence/ia-360-w1/w1-nova-03-agente-criado.png b/evidence/ia-360-w1/w1-nova-03-agente-criado.png index 6c79b688c..0dcc5de48 100644 Binary files a/evidence/ia-360-w1/w1-nova-03-agente-criado.png and b/evidence/ia-360-w1/w1-nova-03-agente-criado.png differ diff --git a/evidence/ia-360-w1/w1-uso-01-tela.png b/evidence/ia-360-w1/w1-uso-01-tela.png index 07eb804d9..814ea1c80 100644 Binary files a/evidence/ia-360-w1/w1-uso-01-tela.png and b/evidence/ia-360-w1/w1-uso-01-tela.png differ diff --git a/evidence/ia-360-w1/w1-uso-02-periodo-vazio.png b/evidence/ia-360-w1/w1-uso-02-periodo-vazio.png index 7b630ee8e..b784cdc28 100644 Binary files a/evidence/ia-360-w1/w1-uso-02-periodo-vazio.png and b/evidence/ia-360-w1/w1-uso-02-periodo-vazio.png differ diff --git a/package.json b/package.json index 501ab28ea..06b36880e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "db:migrate": "echo 'TODO: wire supabase db push or pg-migrate' && exit 0", "db:reset": "supabase db reset", "test:e2e": "playwright test", + "e2e:build": "bash scripts/e2e-build.sh", + "e2e:env": "bash scripts/gerar-env-e2e.sh", "test:journeys": "playwright test -c tests/journeys/playwright.config.ts", "test:unit": "vitest run", "test:db": "bash scripts/test-db.sh", diff --git a/playwright.config.ts b/playwright.config.ts index e07d3369c..ee51779e3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,42 @@ +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 { + 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 = {}; + 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; +} + // 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"; @@ -35,6 +72,16 @@ export default defineConfig({ // 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`. + env: 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. diff --git a/scripts/e2e-build.sh b/scripts/e2e-build.sh new file mode 100755 index 000000000..daaa2a64c --- /dev/null +++ b/scripts/e2e-build.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# `next build` com o ambiente do E2E (Supabase LOCAL). +# +# ═══ POR QUE NÃO `node --env-file=.env.e2e next build` ═══ +# +# Medido: o build morre com +# ERR_WORKER_INVALID_EXEC_ARGV: --env-file= is not allowed in NODE_OPTIONS +# O `next build` cria Workers, e o Node recusa propagar `--env-file` para eles. +# Exportar no shell resolve porque aí são variáveis de ambiente de verdade, que +# os Workers herdam — e que têm precedência sobre os arquivos `.env*` que o Next +# carrega sozinho (é o que impede o `.env.local`, apontado para produção, de +# vencer). +# +# ═══ POR QUE O BUILD PRECISA DISTO E NÃO SÓ O START ═══ +# +# As três `NEXT_PUBLIC_*` são embutidas no BUNDLE aqui. Buildar com o +# `.env.local` e depois trocar o env só no `next start` deixaria a URL de +# produção dentro do JavaScript que roda no browser: o servidor falaria com o +# banco local e o cliente com a nuvem, no mesmo teste. +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ ! -f .env.e2e ]; then + echo "==> .env.e2e não existe. Rode 'pnpm e2e:env' (precisa do Supabase local de pé)." >&2 + exit 1 +fi + +# `set -a` exporta tudo que for atribuído até o `set +a`. +set -a +# shellcheck disable=SC1091 +. ./.env.e2e +set +a + +echo "==> Buildando contra ${NEXT_PUBLIC_SUPABASE_URL}" +pnpm exec next build + +# A PROVA, e não a suposição: se a URL de produção sobreviveu em qualquer +# artefato do bundle, o `.env.local` venceu e o teste falaria com a nuvem pela +# metade cliente. Falhar aqui é barato; descobrir depois de escrever no banco +# real, não. +# +# O host vem do PRÓPRIO .env.local, então a guarda continua valendo se alguém +# apontar aquele arquivo para outro projeto. +if [ -f .env.local ]; then + HOST_PROD="$(grep -E '^NEXT_PUBLIC_SUPABASE_URL=' .env.local | cut -d= -f2- | sed -E 's#https?://##; s#/.*##')" + if [ -n "$HOST_PROD" ] && [ "$HOST_PROD" != "127.0.0.1:54321" ]; then + if grep -rqF "$HOST_PROD" .next/static 2>/dev/null; then + echo "==> FALHOU: o bundle do browser contém o host de produção ($HOST_PROD)." >&2 + echo " O build pegou o .env.local. NÃO rode a suíte — ela escreveria em produção." >&2 + exit 1 + fi + echo "==> OK: o host de produção ($HOST_PROD) não aparece no bundle do browser." + fi +fi + +# Controle POSITIVO do mesmo grep: se a URL local também não aparecesse, o +# "não achei produção" acima não valeria nada — seria um grep que não acha nada. +HOST_LOCAL="$(printf '%s' "$NEXT_PUBLIC_SUPABASE_URL" | sed -E 's#https?://##; s#/.*##')" +if grep -rqF "$HOST_LOCAL" .next/static 2>/dev/null; then + echo "==> OK (controle): o host local ($HOST_LOCAL) ESTÁ no bundle — o grep está vivo." +else + echo "==> FALHOU (controle): o host local não aparece no bundle." >&2 + echo " Sem isto, a ausência de produção acima não prova nada." >&2 + exit 1 +fi diff --git a/scripts/gerar-env-e2e.sh b/scripts/gerar-env-e2e.sh new file mode 100755 index 000000000..fbfa3fc0e --- /dev/null +++ b/scripts/gerar-env-e2e.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Gera o `.env.e2e` — o ambiente da suíte Playwright, apontado para o Supabase +# LOCAL. +# +# ═══ POR QUE ESTE ARQUIVO EXISTE ═══ +# +# O `.env.local` de um checkout de trabalho aponta para o Supabase de PRODUÇÃO +# (é com ele que se desenvolve). O `playwright.config.ts` sobe o app com +# `next start`, que carrega `.env.local`. Resultado, medido em 2026-08-06: +# `pnpm test:e2e` escrevia organizações, usuários e agentes de teste **no banco +# real** — o teste passava, e o estrago era invisível. +# +# A saída não é "lembrar de trocar o .env.local antes de testar": é exatamente o +# tipo de disciplina que falha uma vez e ninguém percebe. +# +# ⚠️ ISTO SOZINHO NÃO BASTA. Os scripts de seed liam `.env.local` DIRETO do +# disco, ignorando `process.env` — então nem o env do webServer os alcançava. +# O conserto do outro lado é `scripts/lib/env-de-teste.ts`, que faz `process.env` +# vencer. Os dois juntos é que fecham o caminho. +# +# Uso: +# pnpm e2e:env # (re)cria o .env.e2e +# pnpm e2e:build && pnpm test:e2e # build embute NEXT_PUBLIC_*, ver e2e-build.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! npx supabase status >/dev/null 2>&1; then + echo "==> O Supabase local não está de pé. Rode 'npx supabase start' antes." >&2 + exit 1 +fi + +ENVOUT="$(npx supabase status -o env 2>/dev/null)" +ler() { printf '%s\n' "$ENVOUT" | grep "^$1=" | cut -d= -f2- | tr -d '"'; } + +API_URL="$(ler API_URL)" +ANON="$(ler ANON_KEY)" +SERVICE="$(ler SERVICE_ROLE_KEY)" + +if [ -z "$API_URL" ] || [ -z "$ANON" ] || [ -z "$SERVICE" ]; then + echo "==> Não consegui ler as chaves do stack local (API_URL/ANON_KEY/SERVICE_ROLE_KEY)." >&2 + exit 1 +fi + +# Guarda contra o erro que este arquivo existe para impedir. Se o `supabase +# status` devolver um host remoto (config apontada para um projeto linkado, por +# exemplo), falhar aqui é melhor do que gerar um `.env.e2e` que manda a suíte +# para a nuvem — o modo de falha silencioso é o caro. +case "$API_URL" in + http://127.0.0.1:*|http://localhost:*) ;; + *) + echo "==> RECUSADO: o stack local respondeu com uma URL que não é local: $API_URL" >&2 + exit 1 + ;; +esac + +cat > .env.e2e < .env.e2e gerado, apontando para $API_URL" +echo "==> Próximo: pnpm e2e:build && pnpm test:e2e" diff --git a/scripts/lib/env-de-teste.ts b/scripts/lib/env-de-teste.ts new file mode 100644 index 000000000..33b43a413 --- /dev/null +++ b/scripts/lib/env-de-teste.ts @@ -0,0 +1,103 @@ +/** + * De onde um script de seed/sonda lê as credenciais do Supabase. + * + * ═══ O DEFEITO QUE ISTO CONSERTA (medido em 2026-08-06) ═══ + * + * Os scripts liam `.env.local` **direto do disco**, ignorando `process.env`: + * + * const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8"); + * + * Num checkout de trabalho, `.env.local` aponta para PRODUÇÃO. Consequência: a + * suíte E2E semeava organizações, usuários e agentes de teste no banco real — e + * o `.env.e2e` no `webServer` do Playwright **não alcançava** esses scripts, + * porque eles nunca olharam para o ambiente. O sintoma que denunciou: o factor + * TOTP gravado em `.e2e-creds.json` não existia no banco local, porque tinha + * sido criado na nuvem. + * + * A org `e2e-test-org` está na produção deste projeto desde 2026-04-29. Ela não + * chegou lá por acidente de uma sessão: chegou porque **este era o comportamento + * normal** da suíte. + * + * ═══ A REGRA ═══ + * + * `process.env` VENCE o arquivo. É o que permite `set -a; . ./.env.e2e` (ou o + * `env` do Playwright) redirecionar qualquer script sem editar nenhum deles. + * Sem valor no ambiente, cai em `.env.local` — o comportamento de sempre, para + * quem roda uma sonda à mão durante o desenvolvimento. + */ +import fs from "node:fs"; +import path from "node:path"; + +export interface CredenciaisSupabase { + url: string; + serviceRole: string; + /** anon key — alguns seeds fazem signIn como usuário comum (ex.: enroll TOTP). */ + anonKey: string; + /** base do app, para links gerados pelo seed. */ + appUrl: string; + /** de onde os valores vieram — vai ao log, para o operador não adivinhar. */ + origem: "ambiente" | "arquivo"; +} + +function lerArquivo(arquivo: string): Record { + const caminho = path.join(process.cwd(), arquivo); + if (!fs.existsSync(caminho)) return {}; + const env: Record = {}; + for (const linha of fs.readFileSync(caminho, "utf8").split("\n")) { + const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(linha); + if (m) env[m[1]!] = (m[2] ?? "").replace(/^"(.*)"$/, "$1").trim(); + } + return env; +} + +/** + * Resolve URL + service role. `process.env` primeiro; `.env.local` depois. + * + * Lança quando não acha — nunca devolve string vazia, que viraria uma chamada + * ao Supabase com credencial vazia e um erro três camadas adiante. + */ +export function credenciaisSupabaseDeTeste(): CredenciaisSupabase { + const doAmbiente = { + url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", + serviceRole: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", + }; + if (doAmbiente.url !== "" && doAmbiente.serviceRole !== "") { + return { + ...doAmbiente, + anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", + origem: "ambiente", + }; + } + + const arquivo = lerArquivo(".env.local"); + const url = arquivo.NEXT_PUBLIC_SUPABASE_URL ?? ""; + const serviceRole = arquivo.SUPABASE_SERVICE_ROLE_KEY ?? ""; + if (url === "" || serviceRole === "") { + throw new Error( + "Sem credenciais do Supabase: defina NEXT_PUBLIC_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY " + + "no ambiente (ex.: `set -a; . ./.env.e2e; set +a`) ou no .env.local.", + ); + } + return { + url, + serviceRole, + anonKey: arquivo.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + appUrl: arquivo.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", + origem: "arquivo", + }; +} + +/** + * Diz em voz alta contra QUAL banco o script vai escrever. + * + * Existe porque o modo de falha caro aqui é silencioso: um seed que escreve na + * produção acha exatamente os mesmos dados de teste de sempre e termina com + * "✅ Seed completo". Uma linha impressa é o que transforma isso em algo que + * alguém pode notar antes de apertar enter na próxima vez. + */ +export function anunciarDestino(script: string, c: CredenciaisSupabase): void { + const local = c.url.startsWith("http://127.0.0.1") || c.url.startsWith("http://localhost"); + const rotulo = local ? "LOCAL" : "⚠️ REMOTO"; + console.info(`[${script}] escrevendo em ${rotulo}: ${c.url} (origem: ${c.origem})`); +} diff --git a/scripts/seed-e2e-credentials.ts b/scripts/seed-e2e-credentials.ts index be6bbc554..fa9c80760 100644 --- a/scripts/seed-e2e-credentials.ts +++ b/scripts/seed-e2e-credentials.ts @@ -14,20 +14,21 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { generateTotp } from "../tests/e2e/utils/totp"; +import { anunciarDestino, credenciaisSupabaseDeTeste } from "./lib/env-de-teste"; -// Carrega .env.local manualmente (sem next/env aqui). -const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8"); -const env: Record = {}; -for (const line of envFile.split("\n")) { - const m = line.match(/^([A-Z_]+)=(.*)$/); - if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1"); -} - -const SUPABASE_URL = env.NEXT_PUBLIC_SUPABASE_URL!; -const SERVICE_ROLE = env.SUPABASE_SERVICE_ROLE_KEY!; -if (!SUPABASE_URL || !SERVICE_ROLE) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in .env.local"); -} +// `process.env` VENCE o `.env.local` (ver scripts/lib/env-de-teste.ts). +// +// A versão anterior lia `.env.local` DIRETO do disco, e por isso a suíte E2E +// semeava org, usuários e agentes no banco de PRODUÇÃO: o `.env.e2e` injetado no +// webServer do Playwright nunca alcançava este script, porque ele não olhava +// para o ambiente. Medido em 2026-08-06 — o factor TOTP em `.e2e-creds.json` não +// existia no banco local porque tinha sido criado na nuvem. +const credenciais = credenciaisSupabaseDeTeste(); +anunciarDestino("seed-e2e-credentials", credenciais); +const SUPABASE_URL = credenciais.url; +const SERVICE_ROLE = credenciais.serviceRole; +const ANON_KEY = credenciais.anonKey; +const APP_URL = credenciais.appUrl; const admin = createClient(SUPABASE_URL, SERVICE_ROLE, { auth: { autoRefreshToken: false, persistSession: false }, @@ -204,7 +205,7 @@ async function ensureAdminTotp(adminUserId: string, adminEmail: string): Promise console.log(`[seed] admin TOTP factor removed (rotating): ${f.id}`); } - const anon = createClient(SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { + const anon = createClient(SUPABASE_URL, ANON_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }); const { error: signInErr } = await anon.auth.signInWithPassword({ @@ -258,9 +259,9 @@ async function main(): Promise { users, admin_totp: adminTotp, default_agent_id: agentId, - app_url: env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", + app_url: APP_URL, supabase_url: SUPABASE_URL, - supabase_anon_key: env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + supabase_anon_key: ANON_KEY, }; fs.writeFileSync(".e2e-creds.json", JSON.stringify(creds, null, 2));