Merge pull request #983 from webtecnica/fix/179-vps-fresh-onboarding-no-ci

test(e2e): onboarding da VPS fresca roda no CI com WAHA, Redis e dublê real de SaaS
This commit is contained in:
Rafael Melgaço
2026-09-19 01:51:36 -03:00
committed by GitHub
13 changed files with 1509 additions and 89 deletions
+7
View File
@@ -0,0 +1,7 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A verificação de VPS recém-instalada passa a rodar no CI
---
Nada muda na sua VPS: nenhuma migration, nenhuma variável, nenhuma imagem. O que muda é o que o pipeline mede antes de a release sair — a verificação da instalação fresca (`vps-fresh-onboarding`), que existia e nunca tinha rodado em lugar nenhum, passa a rodar a cada mudança, com WAHA, Redis (a mesma tradução REST do Upstash do `docker-compose.prod.yml`) e um destino HTTP real para Resend e Nuvemshop. O primeiro dono é criado pelo mesmo `scripts/bootstrap-owner.ts` que o `install.sh` roda na sua VPS. Crédito: @webtecnica.
+322 -20
View File
@@ -5,13 +5,13 @@ name: e2e
#
# NÃO conte aqui quantas são: o summary do job CONTA em vez de afirmar, e
# `tests/unit/e2e-cobertura-completa.test.ts` reprova o build quando uma spec do
# disco não está em nenhuma das três listas abaixo. Um número escrito nesta linha
# disco não está em nenhuma das quatro listas abaixo. Um número escrito nesta linha
# é a quarta fonte da mesma verdade, e as três anteriores já divergiram — este
# cabeçalho dizia "29 de 33" quando o disco tinha 45 e o job rodava 44.
#
# A execução é dividida em DUAS invocações do playwright de propósito: o
# limitador de login do produto é por IP (60/300s) e no CI todos os specs vêm do
# mesmo 127.0.0.1. Ver o comentário do passo "E2E — parte 1 de 3".
# mesmo 127.0.0.1. Ver o comentário do passo "E2E — parte 1 de 4".
#
# É CHECK OBRIGATÓRIO. A régua, para reconferir em vez de acreditar nesta linha:
# $ gh api repos/melgarafael/DeskcommCRM/branches/main/protection \
@@ -139,6 +139,16 @@ jobs:
# O teto de 30 min FICA. Ele é o que denuncia a suíte crescendo de novo;
# subi-lo seria trocar um vermelho honesto por um CI que demora mais a cada
# mês sem ninguém perceber.
#
# A parte 4 já custou às outras três: os contêineres dela (WAHA, redis,
# serverless-redis-http) eram `services:`, que é chave do JOB e sobe em
# TODAS as pernas — o Actions não aceita condição por braço da matriz. Na
# parte 1 do run 35124188017, que não usa nenhum deles, "Initialize
# containers" foi de 16:53:22.468 a 16:54:13.725 — 51s, quase todos no pull
# do WAHA —, com a parte 3 do mesmo run em 26m04s. Agora eles sobem por
# `docker run` no passo "Subir WAHA e o par Redis da VPS fresca", com
# `if: matrix.parte == 4`. Não os devolva para `services:`: a guarda
# `tests/unit/e2e-parte-4-fala-com-os-servicos-do-runner.test.ts` reprova.
e2e-parte:
needs: [e2e-alcance]
if: needs.e2e-alcance.outputs.e2e == 'sim'
@@ -174,7 +184,7 @@ jobs:
# uma rodada por parte.
fail-fast: false
matrix:
parte: [1, 2, 3]
parte: [1, 2, 3, 4]
# ─────────────────────────────────────────────────────────────────────────
# A COBERTURA DEIXA DE SER PROSA DIGITADA À MÃO.
#
@@ -187,7 +197,7 @@ jobs:
# Cobertura parcial silenciosa se lê como cobertura total. Agora há UMA fonte
# por lista, o summary CONTA em vez de afirmar, e
# `tests/unit/e2e-cobertura-completa.test.ts` reprova o build quando um arquivo
# de spec não está em nenhuma das três listas.
# de spec não está em nenhuma das quatro listas.
#
# FORA_DO_CI exige motivo escrito no próprio nome do bloco abaixo — spec fora
# do gate sem razão declarada é a dívida voltando pela porta de serviço.
@@ -641,7 +651,11 @@ jobs:
# Cada item aqui tem o motivo MEDIDO, não presumido — e o gate cobra que a
# lista exista e some com as outras duas.
#
# `vps-fresh-onboarding` fica fora por DUAS razões, e só a primeira estava
# ⚠️ HISTÓRICO: `vps-fresh-onboarding` NÃO está mais fora — desde o PR da
# issue #179 ela roda na PARTE_4 (ver o bloco `SPECS_PARTE_4` abaixo). O
# registro fica pelas lições, e o fecho dele foi atualizado.
#
# `vps-fresh-onboarding` ficava fora por DUAS razões, e só a primeira estava
# escrita — com o número errado, contradito 8 linhas abaixo neste mesmo
# arquivo, que já usava 17 como controle positivo:
#
@@ -670,11 +684,14 @@ jobs:
#
# A instância está corrigida (a org sai do dono do bootstrap) e a
# classe inteira virou gate: `tests/unit/e2e-nao-escolhe-a-primeira-linha`.
# O que continua valendo aqui é a razão (1): esta suíte não roda no CI
# por dependência externa, não por causa do rig de organização.
# A razão (1) — dependência externa — era a que ainda a segurava, e
# foi a que a PARTE_4 resolveu: WAHA e o par Redis sobem por
# `docker run` só nela, e Resend/Nuvemshop falam com um dublê HTTP real.
#
# Continua sendo a P0 da doutrina de QA Visual: `e2e` verde NÃO prova a
# jornada de instalação fresca, que é o produto que se vende.
# Ela é a P0 da doutrina de QA Visual, e é por isso que o limite da prova
# fica escrito onde ela roda: o WAHA do CI é o CORE, não o Plus (ver o
# passo "Subir WAHA e o par Redis da VPS fresca") — a jornada de onboarding é exercitada; o que só
# o Plus tem, não.
#
# As CINCO specs da missão "Follow-up Vivo" (`followup-linguagem`,
# `followup-ramos`, `followup-dossie`, `followup-tempo-adaptativo`,
@@ -850,8 +867,40 @@ jobs:
followup-modelos-de-clinica.spec.ts
presenca-do-atendente.spec.ts
FORA_DO_CI: >-
# ─── PARTE 4 — a spec de VPS fresca, e por que ela vem sozinha ───
#
# A `vps-fresh-onboarding` era a última spec fora do CI. A infraestrutura
# que faltava chegou em dois lugares:
#
# * o passo "Subir WAHA e o par Redis da VPS fresca" (`docker run`, só
# nesta parte): WAHA (devlikeapro/waha) e o par Redis do
# `docker-compose.prod.yml` — `redis:7-alpine` + o
# `hiett/serverless-redis-http` que fala o REST do Upstash;
# * `scripts/duble-saas-e2e.mjs`: dublê HTTP REAL (recebe requisição de
# verdade, na porta, com processo separado) do Resend e do handshake
# OAuth da Nuvemshop. Não é mock em processo — a doutrina de QA visual
# pede receiver real para efeito colateral externo.
#
# Ela fica SOZINHA numa parte por um motivo que não é relógio: as partes 1
# a 3 explicitamente NÃO resetam o banco entre specs (é a vizinhança que
# elas escolheram), e a fresca precisa do oposto — a instalação sem dono,
# sem tenant, sem fixtures. Por isso os dois passos de semeadura
# (`Semear credenciais de teste` e `Semear as fixtures`) recebem
# `if: matrix.parte != 4`, e o único dado que existe antes dela é o dono
# do bootstrap — criado pelo passo `Criar o dono da instalação`, com o
# mesmo `scripts/bootstrap-owner.ts` que o `install.sh` roda na VPS. A
# spec EXIGE esse dono como precondição (está no cabeçalho dela, junto de
# WAHA e Redis); o que ela afirma é a jornada a partir dali, não a
# existência do usuário.
SPECS_PARTE_4: >-
vps-fresh-onboarding.spec.ts
# ─── FORA_DO_CI — as duas que ficaram, e o motivo continua onde sempre ───
#
# A `vps-fresh-onboarding` saiu desta lista no PR da issue #179 (a infra
# dela está no `SPECS_PARTE_4` logo acima). As duas abaixo continuam
# fora, e o motivo delas é o que o job `resumo` imprime no run.
FORA_DO_CI: >-
inbox-tempo-real.spec.ts
cadastro-sem-confirmacao-de-email.spec.ts
steps:
@@ -885,9 +934,10 @@ jobs:
# valor e o `timeout-minutes` deixarem de concordar.
echo "TETO_SEGUNDOS=1800"
# Corte antecipado, 2 min antes do teto. A margem paga o que o
# relógio de passo NÃO vê: o `Initialize containers` dos services
# roda antes do primeiro passo (~51 s por perna, medido no #983) e
# o envio de artefato/resumo precisa de tempo depois da suíte.
# relógio de passo NÃO vê: o que o runner faz antes do primeiro passo
# (até o #983 incluía ~51 s de `Initialize containers` em toda
# perna; os contêineres agora sobem só na parte 4, dentro de um
# passo) e o envio de artefato/resumo depois da suíte.
echo "MARGEM_SEGUNDOS=120"
# Piso para a suíte sequer começar: com menos que isto, rodar a
# suíte é gastar o que resta para ver um cancelamento no meio.
@@ -897,6 +947,99 @@ jobs:
- uses: actions/checkout@v7
# ─── Serviços da parte 4 — a spec de VPS fresca (issue #179) ──────────
#
# Eram `services:` do job, e `services:` é chave do JOB, não do braço da
# matriz: subiam nas quatro partes e ficavam ociosos em três. Na parte 1
# do run 35124188017, que não usa nenhum deles, "Initialize containers"
# custou 51s (quase todos no pull do WAHA), com as partes a ~2 min do
# teto de 30. Aqui eles sobem por `docker run` SÓ na parte 4 — a alavanca
# que o comentário do teto, no topo deste job, nomeava.
#
# A rede própria devolve o que `services:` dava de graça: o nome `redis`
# resolvendo para o contêiner do Redis, que é o endereço do
# serverless-redis-http. As portas publicadas são as mesmas (3000, 6379,
# 8079), então o passo "Ligar a VPS fresca" — que espera cada serviço
# RESPONDER e falha fechado — não muda. O passo sobe logo no começo do
# job para os contêineres ficarem prontos enquanto o Supabase e o build
# correm; por isso eles continuam nascendo ANTES de o `.env.e2e` existir.
- if: matrix.parte == 4
name: Subir WAHA e o par Redis da VPS fresca
env:
# Tag PINADA, e a MESMA do `docker-compose.prod.yml`: este job existe
# para provar o que o self-hoster recebe, e `:latest` deixou de ser
# isso em 01/09/2026 (o digest do `latest` virou 2026.8.2 enquanto o
# pin segue em 2026.7.2). Medido no run 35095923107: com `:latest` o
# contêiner NÃO sobe.
WAHA_IMAGE: devlikeapro/waha:latest-2026.7.2
REDIS_IMAGE: redis:7-alpine
# O MESMO digest do compose: `:latest` é tag móvel. Medido em
# 2026-09-16 com `docker buildx imagetools inspect`: o `:latest` de
# então É este índice. Bumpar é junto com o compose.
SRH_IMAGE: hiett/serverless-redis-http@sha256:5b0bb9239fce53abf87b2018a7a0deb9ec7bd900c5360738fe5fbeeb426f9150
# Inerte nesta jornada, e escrito para não parecer que funciona: o job
# roda no host, então o 127.0.0.1 de dentro do WAHA é o próprio WAHA.
# O que a spec exercita é app→WAHA (o QR), não o webhook de volta.
WHATSAPP_HOOK_URL: http://127.0.0.1:3001/api/whatsapp/webhook
# A chave do WAHA tem UMA fonte: `.env.e2e`, gerado por
# `scripts/gerar-env-e2e.sh` e publicado no ambiente do job. Escrever a
# chave aqui foi reprovado pelo guard
# `tests/unit/e2e-workflow-honra-o-env.test.ts` (eram DOIS valores
# diferentes, e o app levava 401) — e este passo roda ANTES do que
# publica o arquivo (é o que deixa o pull correr junto com o resto do
# job), então o contêiner não tem como receber o valor da fonte única.
#
# Daí o `WAHA_NO_API_KEY`: sem ele o WAHA NÃO roda sem chave — ele
# SORTEIA uma no boot. Está no pin, em `core/auth/config.js`:
# `FromEnv('WAHA_API_KEY', parseBool(process.env.WAHA_NO_API_KEY), rand(), keys)`,
# e o `ApiKeyAuthFactory` só cai em `NoAuth` quando a chave resolve
# vazia. Medido no run 35100039158: com a chave sorteada, todo
# `X-Api-Key` que o CRM manda — o do `.env.e2e` — toma 401, e o
# serviço fica de pé e inútil. Com `True` a API é pública (o próprio
# contêiner avisa no log: "No API key detected"), e o que este job
# exercita é a jornada de onboarding, não o contrato de autenticação do
# WAHA — esse tem fonte e teste no produto.
WAHA_NO_API_KEY: "True"
# O MESMO motor do `docker-compose.prod.yml`
# (`WHATSAPP_DEFAULT_ENGINE: ${WHATSAPP_DEFAULT_ENGINE:-NOWEB}`). Sem
# esta linha o contêiner cai no default WEBJS — o log do run
# 35124188017 mostra `"engine":"WEBJS"` —, e o produto RECUSA essa
# sessão: `compatibleSession()` em `lib/waha/client.ts` devolve false
# para qualquer engine que não seja NOWEB, e o `createSession` lança.
# Com WEBJS a jornada do QR não teria como ficar verde nem com o WAHA
# alcançável.
#
# Limite escrito para não ser descoberto: isto é o WAHA CORE (o mesmo
# log diz `"tier":"CORE"`), não o WAHA Plus que a doutrina exige em
# produção. A licença do Plus não pode viver num CI que roda PR de
# fork sem segredo nenhum. Multi-sessão, retry e S3 — o que só o Plus
# tem — NÃO são exercitados aqui; a jornada de onboarding é.
WHATSAPP_DEFAULT_ENGINE: NOWEB
run: |
set -euo pipefail
docker network create vps-fresca
docker run -d --name redis --network vps-fresca --network-alias redis \
-p 6379:6379 "$REDIS_IMAGE"
# O serverless-redis-http conecta no boot: ele só sobe com o Redis de pé.
for _ in $(seq 1 30); do
if docker exec redis redis-cli ping 2>/dev/null | grep -q PONG; then break; fi
sleep 1
done
if ! docker exec redis redis-cli ping 2>/dev/null | grep -q PONG; then
echo "o redis não respondeu PING em 30s"; docker logs --tail 40 redis 2>&1 || true
exit 1
fi
docker run -d --name redis-http --network vps-fresca -p 8079:80 \
-e SRH_MODE=env -e SRH_TOKEN=e2e-token-nao-e-segredo \
-e SRH_CONNECTION_STRING=redis://redis:6379 "$SRH_IMAGE"
docker run -d --name waha -p 3000:3000 \
-e WHATSAPP_HOOK_URL -e WAHA_NO_API_KEY -e WHATSAPP_DEFAULT_ENGINE "$WAHA_IMAGE"
docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
- uses: ./.github/actions/preparar-node
# O teto do preâmbulo mora aqui porque passo de composite action não
# aceita `timeout-minutes`. Razão e medições: o cabeçalho da action.
@@ -1066,7 +1209,164 @@ jobs:
# O seed cria 1 org + 4 usuários com os 4 papéis e um TOTP verified de
# secret conhecido no admin — sem ele, todo spec que loga fica de fora, que
# era a maior parte da suíte.
- name: Semear credenciais de teste
# ─── A parte fresca: WAHA, Redis e o dublê de SaaS, antes da spec ──────
#
# O dublê é exercitado pelo SMOKE antes da spec: se ele não responder o
# contrato, o job falha aqui, com a rota nomeada, em vez de falhar seis
# minutos depois dentro do Playwright com "elemento não encontrado".
- if: matrix.parte == 4
name: Ligar a VPS fresca (WAHA, Redis e dublê de SaaS)
run: |
set -euo pipefail
# 1. O dublê de Resend/Nuvemshop: processo separado, porta de verdade.
nohup node scripts/duble-saas-e2e.mjs >/tmp/duble-saas.log 2>&1 &
for _ in $(seq 1 30); do
if curl -sf http://127.0.0.1:3997/__duble/saude >/dev/null; then break; fi
sleep 1
done
cat /tmp/duble-saas.log
bash scripts/duble-saas-e2e-smoke.sh
# 2. O que a spec fresca exige do ambiente (cabeçalho dela):
# WAHA ativo, Redis local, e Resend SEM chave — instalação nova não
# tem chave de SaaS nenhuma, e é isso que a spec cobra.
{
# `WAHA_API_KEY` NÃO entra aqui: ela vem do `.env.e2e` (fonte única,
# já publicada no ambiente do job). Redigitá-la — com outro valor,
# que era o caso — é a segunda fonte que o guard
# `tests/unit/e2e-workflow-honra-o-env.test.ts` reprova.
echo "WAHA_API_BASE_URL=http://127.0.0.1:3000"
echo "UPSTASH_REDIS_REST_URL=http://127.0.0.1:8079"
echo "UPSTASH_REDIS_REST_TOKEN=e2e-token-nao-e-segredo"
echo "RESEND_API_BASE_URL=http://127.0.0.1:3997"
echo "RESEND_API_KEY="
echo "NUVEMSHOP_AUTH_BASE=http://127.0.0.1:3997"
echo "NUVEMSHOP_API_BASE=http://127.0.0.1:3997"
echo "NUVEMSHOP_CLIENT_ID=e2e-app-id"
echo "NUVEMSHOP_CLIENT_SECRET=e2e-placeholder-nao-e-segredo"
} > /tmp/vps-fresca.env
# ⚠️ Estes valores vão para DENTRO do `.env.e2e`, não só para o
# `$GITHUB_ENV` — senão o SERVIDOR nunca os vê.
#
# O `playwright.config.ts` injeta o `.env.e2e` em `webServer.env`, e
# ali a regra medida (escrita no próprio config) é: chave nos dois
# lados, vence a do `.env.e2e`. O gerador escreve
# `WAHA_API_BASE_URL=…:3999` e `UPSTASH_REDIS_REST_URL=…:3998`
# (portas onde nada escuta), então só anexar ao `$GITHUB_ENV` dava
# 3000/8079 ao processo de TESTE e 3999/3998 ao app. Medido no run
# 35124188017 (parte 4): o contêiner do WAHA registrou DUAS
# requisições no job inteiro — a sonda deste passo e o `beforeAll`
# da spec, que leem o ambiente do processo —, e nenhuma do app. No
# trace, o POST de sessão do onboarding voltou 502 sem `details`
# (nenhuma resposta HTTP do WAHA) e os 19 GETs de status, 502. As 6
# linhas `redis incr failed … fetch failed` do app são a mesma
# causa, do lado do Redis.
#
# Trocar as linhas NO arquivo mantém a fonte única: servidor, testes,
# `.env.local` (copiado logo abaixo) e `$GITHUB_ENV` leem o mesmo.
chaves=$(cut -d= -f1 /tmp/vps-fresca.env | paste -sd'|' -)
grep -vE "^(${chaves})=" .env.e2e > /tmp/env-e2e-sem-parte-4
cat /tmp/env-e2e-sem-parte-4 /tmp/vps-fresca.env > .env.e2e
cat /tmp/vps-fresca.env >> "$GITHUB_ENV"
echo "--- o que o app da parte 4 recebe do .env.e2e ---"
grep -E "^(${chaves})=" .env.e2e
# 3. A cadeia de Redis, provada pelo protocolo que o produto usa: um
# comando Upstash REST de verdade, que só responde PONG se o
# serverless-redis-http estiver de pé E falando com o redis:7.
#
# Falha FECHADO, como o laço do WAHA logo abaixo. A versão anterior
# terminava o `for` sem `exit 1`: nas 30 voltas sem PONG o passo
# seguia verde e o vermelho aparecia minutos depois, dentro do
# Playwright, sem nome. Medido no run 35124188017 (parte 4): a linha
# "pronto" nunca foi impressa, 30,3s de vão entre o smoke do dublê e
# o WAHA, e o passo verde.
#
# O `Content-Type` é o motivo de nunca ter passado. Sem ele o `curl
# -d` manda `application/x-www-form-urlencoded`, e o
# serverless-redis-http recusa: HTTP 400 `{"error":"Invalid content
# type. Expected application/json."}`. Com ele: 200
# `{"result":"PONG"}`. Medido contra o MESMO digest do
# `docker-compose.prod.yml`, com o redis:7-alpine atrás. O SDK
# `@upstash/redis` do produto manda o cabeçalho; a sonda tem de
# mandar também, senão prova um protocolo que ninguém usa.
redis_pronto=""
for _ in $(seq 1 30); do
resposta=$(curl -s -X POST \
-H "Authorization: Bearer e2e-token-nao-e-segredo" \
-H "Content-Type: application/json" \
-d '["PING"]' -w ' http=%{http_code}' \
http://127.0.0.1:8079/ || true)
if printf '%s' "$resposta" | grep -q PONG; then
echo "Redis (REST do Upstash) pronto"
redis_pronto=1
break
fi
sleep 1
done
if [ -z "$redis_pronto" ]; then
echo "Redis (REST do Upstash) não respondeu PONG em 30s: $resposta"
echo "--- o que os contêineres do Redis dizem ---"
docker ps -a --filter name=redis --format '{{.Names}} {{.Status}} {{.Ports}}' || true
docker logs --tail 40 redis-http 2>&1 || true
exit 1
fi
# 4. Alguns scripts leem o disco em vez do ambiente.
cp .env.e2e .env.local
# 5. Espera o WAHA responder DE VERDADE antes de entregar o turno para o
# Playwright, e sonda a rota que o CRM usa (`/api/sessions`, com a
# MESMA chave do `.env.e2e`). O passo antigo esperava
# `curl -sf http://127.0.0.1:3000/api/health`: rota que NÃO EXISTE
# neste pin — o health é `/health` (e exige chave); `/ping` é o único
# endpoint público. Eram 404 a cada volta, 120s de espera e o job
# vermelho em "WAHA não respondeu" (run 35100039158). `/ping` sozinho
# diria apenas que o processo subiu, não que o CRM consegue falar com
# ele.
for _ in $(seq 1 60); do
ping=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ping || true)
sessoes=$(curl -s -o /dev/null -w '%{http_code}' -H "X-Api-Key: $WAHA_API_KEY" \
"http://127.0.0.1:3000/api/sessions?all=true" || true)
if [ "$ping" = "200" ] && [ "$sessoes" = "200" ]; then
echo "WAHA pronto (/ping 200 · /api/sessions 200)"
exit 0
fi
sleep 2
done
echo "WAHA não respondeu em 120s: /ping=$ping · /api/sessions=$sessoes"
echo "--- o que o contêiner do WAHA diz ---"
docker ps -a --filter name=waha --format '{{.Names}} {{.Status}} {{.Ports}}' || true
docker logs --tail 40 waha 2>&1 || true
exit 1
# ─── O dono da instalação fresca: o `install.sh` cria, e o CI também ───
#
# PRECONDIÇÃO da spec, declarada por ela no cabeçalho: "primeiro usuário
# criado via scripts/bootstrap-owner.ts (como o install.sh)". Sem este
# passo o `beforeAll` dela morre antes do primeiro teste, com
# "esta suite APAGA dados da organizacao que resolver aqui, e nao achou o
# dono (dono@qa.local)" — a spec é destrutiva e se recusa a escolher a
# organização no escuro (a versão antiga escolhia "a primeira" e zerou o
# onboarding e a sessão de WhatsApp de uma instalação de trabalho).
#
# O que este passo NÃO faz: não semeia fixture, não pula wizard, não
# inventa tenant. Ele cria exatamente o que o `install.sh` cria numa VPS
# recém-instalada — dono (auth, e-mail confirmado), organização, vínculo
# como `admin` e a linha em `platform_admins` — e entrega a jornada
# (wizard, WhatsApp, convites) para a spec percorrer.
#
# As credenciais do dono não estão escritas aqui: vêm do `.env.e2e`, que
# é a fonte única do ambiente da suíte e já foi publicada no job no passo
# "Publicar o .env.e2e no ambiente do job". O script lê o ambiente.
- if: matrix.parte == 4
name: Criar o dono da instalação (como o install.sh)
run: pnpm exec tsx scripts/bootstrap-owner.ts
- if: matrix.parte != 4
name: Semear credenciais de teste
run: |
cp .env.e2e .env.local
pnpm exec tsx scripts/seed-e2e-credentials.ts
@@ -1093,7 +1393,8 @@ jobs:
# capacidades importa `lib/env.ts` (via lib/ai/embed.ts), que lê
# `process.env`; o de escalação parseia o `.env.local` por conta própria.
# Sem a flag o de capacidades morre na validação Zod das 3 vars do Supabase.
- name: Semear as fixtures que as specs não semeiam sozinhas
- if: matrix.parte != 4
name: Semear as fixtures que as specs não semeiam sozinhas
run: |
pnpm exec tsx scripts/seed-e2e-escalacao.ts
pnpm exec tsx --env-file=.env.local scripts/seed-e2e-capacidades-ausentes.ts
@@ -1135,7 +1436,7 @@ jobs:
# workers, dois specs gerando o mesmo código no mesmo intervalo de 30s
# fazem o segundo ser recusado como replay. Foi o que derrubava a
# `reset-password-mfa` (issue #80): ela passa sozinha e falha em paralelo.
- name: E2E — parte ${{ matrix.parte }} de 3
- name: E2E — parte ${{ matrix.parte }} de 4
run: |
set -euo pipefail
# A lista sai da matrix, e as duas variáveis continuam existindo no
@@ -1146,6 +1447,7 @@ jobs:
1) LISTA="$SPECS_PARTE_1" ;;
2) LISTA="$SPECS_PARTE_2" ;;
3) LISTA="$SPECS_PARTE_3" ;;
4) LISTA="$SPECS_PARTE_4" ;;
*) echo "::error::parte ${{ matrix.parte }} sem lista"; exit 1 ;;
esac
# Falha alto se a variável vier vazia: sem isto, um typo no nome faria
@@ -1437,13 +1739,13 @@ jobs:
dentro { exit }
' .github/workflows/e2e.yml | grep -oE "[a-z0-9-]+\.spec\.ts" | sort -u; }
NO_DISCO=$(ls tests/e2e/*.spec.ts | wc -l | tr -d " ")
RODOU=$( { bloco SPECS_PARTE_1; bloco SPECS_PARTE_2; bloco SPECS_PARTE_3; } | sort -u | wc -l | tr -d " ")
RODOU=$( { bloco SPECS_PARTE_1; bloco SPECS_PARTE_2; bloco SPECS_PARTE_3; bloco SPECS_PARTE_4; } | sort -u | wc -l | tr -d " ")
FORA_LISTA=$(bloco FORA_DO_CI)
FORA=$(echo "$FORA_LISTA" | grep -c . || true)
# E o recorte passa a ter CONTROLE, não só um piso.
#
# `tests/unit/e2e-cobertura-completa.test.ts` já garante que toda spec
# do disco está em exatamente uma das três listas, sem duplicata e sem
# do disco está em exatamente uma das quatro listas, sem duplicata e sem
# fantasma. Logo `RODOU + FORA` TEM de dar `NO_DISCO` — e quando não
# der, o recorte leu a mais ou a menos. Sem esta linha, ler a mais é
# invisível: o passo imprime um número maior e segue verde, que é
@@ -1461,8 +1763,8 @@ jobs:
echo "**Rodou: ${RODOU} de ${NO_DISCO} specs**, em partes paralelas."
echo ""
echo "**Não rodou (${FORA}):**"
for s in $FORA_LISTA; do echo "- \`${s}\` — precisa de WAHA + Redis + Resend + Nuvemshop (P0 da doutrina de QA Visual)"; done
for s in $FORA_LISTA; do echo "- \`${s}\` — fora do CI de propósito: o motivo de cada uma está no comentário do bloco FORA_DO_CI, no topo deste workflow"; done
echo ""
echo "A soma é conferida mecanicamente por \`tests/unit/e2e-cobertura-completa.test.ts\`:"
echo "spec no disco que não esteja em nenhuma das três listas reprova o build."
echo "spec no disco que não esteja em nenhuma das quatro listas reprova o build."
} >> "$GITHUB_STEP_SUMMARY"
+3 -1
View File
@@ -20,7 +20,9 @@ os bugs achados na causa raiz.
pela matriz do job `invariants` (`pnpm test:db` em pg15 e pg17), não por este
ambiente.
- **Primeiro usuário:** `scripts/bootstrap-owner.ts` (como o `install.sh`):
`dono@qa.local` / `QaVps!2026#Dono`, org "Loja QA VPS".
`dono@qa.local` / `QaVps!2026#Dono`, org `Loja-QA-VPS` — nome de organização
**sem espaço**, porque o `.env.e2e` é carregado por `source` (espaço vira
comando) e publicado literal no `$GITHUB_ENV` (aspa viraria parte do nome).
- **Deps:** WAHA Core local (`deskcomm-waha`, :3030), Redis + serverless-redis-http
(`qa-redis`/`qa-srh`, :8079), cron drain via endpoint.
- **App:** `next build` + `next start` na :3001, `NODE_ENV=production`.
+2 -2
View File
@@ -35,7 +35,7 @@ fonte só (`lib/onboarding/passos.ts`) — eram três listas que discordavam. Ga
| J1.4 | Welcome: nome da org + timezone salvos | grava `display_name`/`timezone`, avança pro WhatsApp |
| J1.5 | Connect WhatsApp: WAHA ativo → QR aparece | sessão criada, QR renderiza via proxy, poll de status roda |
| J1.6 | Connect WhatsApp: "Pular por enquanto" | avança pro step correto (setup-ai quando Nuvemshop off) |
| J1.7 | Setup IA: criar agente default | `ai_agents` criado **e a versão publicada aponta para o provedor que a instalação escolheu**, com o modelo curado DAQUELE provedor; avança |
| J1.7 | Setup IA: criar agente default | `ai_agents` criado **e a versão publicada aponta para o provedor que a instalação escolheu**, com o modelo curado DAQUELE provedor; avança. **Sem chave de IA** (o `install.sh` deixa pular, e é o ambiente da VPS fresca no CI): o agente nasce rascunho, sem versão, o aviso nomeia o provedor e "Continuar sem publicar" avança para "Onde ele organiza" (`tests/e2e/vps-fresh-onboarding.spec.ts`) |
| J1.8 | Invite team: enviar convite SEM Resend configurado (realidade da VPS fresca) | UI **não mente**: mostra que email não saiu + oferece `accept_url` copiável |
| J1.9 | Done: "Ir para o Inbox" | seta `onboarded_at`, cai no `/app/inbox` |
| J1.10 | Gate MFA pós-onboarding | blocker aparece; enrolar TOTP + ver/salvar recovery codes funciona de ponta a ponta |
@@ -55,7 +55,7 @@ fonte só (`lib/onboarding/passos.ts`) — eram três listas que discordavam. Ga
| J1.24 | Ver o funcionário atender antes de terminar | passo novo entre treinar e chamar o time: ensaio com o runtime real (`is_dry_run`), nada enviado pelo WhatsApp. Trata os três estados — sem agente, agente em rascunho, e o caso normal — e o erro aparece aqui, não com o primeiro cliente de verdade · **PASS** (`tests/e2e/vps-fresh-onboarding.spec.ts`, `lib/onboarding/passos.test.ts`) |
| J1.25 | O passo 1 mostra o que a instalação já trouxe | provedor contratado, WhatsApp pronto, funil criado — cada linha MEDIDA. E o campo de nome vem vazio quando a organização ainda está com o "Minha Empresa" do instalador, em vez de obrigar a pessoa a apagá-lo · **PASS** (`lib/instalacao/ambiente.test.ts`) |
| J1.26 | O quadro de clientes deixa de nascer de e-commerce | passo novo entre treinar e ver ele atender. `trg_seed_default_pipeline_for_org` semeia "Carrinho abandonado / Em separação / Enviado" em TODA organização, e a clínica abria o quadro dela e lia isso. A sugestão sai do MESMO modelo que vai atender — se ela falha, o dono descobre agora e não com o primeiro cliente · **PASS** (`tests/e2e/wizard-do-funcionario.spec.ts`, `lib/onboarding/proposta-de-funil.test.ts`) |
| J1.26 | O quadro de clientes deixa de nascer de e-commerce | passo novo entre treinar e ver ele atender. `trg_seed_default_pipeline_for_org` semeia "Carrinho abandonado / Em separação / Enviado" em TODA organização, e a clínica abria o quadro dela e lia isso. A sugestão sai do MESMO modelo que vai atender — se ela falha, o dono descobre agora e não com o primeiro cliente · **PASS** (`tests/e2e/wizard-do-funcionario.spec.ts`, `lib/onboarding/proposta-de-funil.test.ts`; sem chave de IA, na VPS fresca: `tests/e2e/vps-fresh-onboarding.spec.ts` — o aviso "ainda não está no ar" + modelo pronto, e o que a tela mostra é o que se grava) |
| J1.27 | O quadro **ensina o funcionário a percorrê-lo** | MEDIDO em 2026-08-13: **312 etapas em 43 funis, 4 com `agent_stage_hint`** — e as 4 de organizações de teste. Toda instalação real nascia com `coberturaDoFunil()` devolvendo `mudo: true`: o assistente tinha o funil no escopo (J1.20) e não sabia o que significava nenhuma coluna. Aqui uma coluna é NOME + DESTINO indissociáveis · **PASS** (`tests/invariants/quadro-do-onboarding.test.ts`, 7 casos contra o Postgres do baseline) |
| J1.28 | Sem chave de IA, o passo ainda entrega quadro | falha ABERTA na informação, FECHADA na ação: seis quadros prontos por ramo, escolhidos pelo que o dono escreveu no passo 1, e a tela DIZ que a sugestão não veio. Devolver erro deixaria a pessoa com o funil de e-commerce, que é o defeito que o passo existe para consertar · **PASS** (`lib/onboarding/sugerir-funil.test.ts`, `tests/e2e/wizard-do-funcionario.spec.ts`) |
| J1.29 | O passo 1 pergunta **o que o negócio faz** | era o dado que faltava no produto inteiro: sem ele os três modelos de prompt diziam "loja online" e o quadro nascia de e-commerce — os dois defeitos vinham da mesma origem, uma instalação que nunca pergunta em que ramo entrou · **PASS** (`tests/e2e/wizard-do-funcionario.spec.ts`) |
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Prova de vida do dublê de SaaS do e2e (issue #179).
#
# Exercita o CONTRATO inteiro do `scripts/duble-saas-e2e.mjs` — Resend e o
# handshake OAuth da Nuvemshop incluído — contra a porta onde ele subiu. Serve
# a dois consumidores, e é por isso que ele existe como script e não como um
# bloco de `curl` solto dentro do YAML:
#
# 1. o job do CI, que o roda ANTES da spec. Se o dublê não responde o
# contrato, o job falha aqui, com a rota nomeada, em vez de falhar 6
# minutos depois dentro do Playwright com "elemento não encontrado";
# 2. quem está na VPS sem Docker, onde a spec Playwright não roda: dá para
# provar o dublê localmente com `bash scripts/duble-saas-e2e-smoke.sh`.
#
# Uso: bash scripts/duble-saas-e2e-smoke.sh [http://127.0.0.1:3997]
#
# Sem dependência de `jq` (o runner tem, a VPS pode não ter): o que se cobra é
# o status HTTP e a presença de campos, com `grep -q`.
set -u -o pipefail
BASE="${1:-http://127.0.0.1:3997}"
CHAVE_RESEND="${E2E_RESEND_API_KEY:-re_placeholder_nao_e_segredo}"
SEGREDO_NUVEMSHOP="${NUVEMSHOP_CLIENT_SECRET:-e2e-placeholder-nao-e-segredo}"
FALHAS=0
# `-s -o corpo -w status`: o corpo fica no arquivo, o status na variável.
CORPO="$(mktemp)"
trap 'rm -f "$CORPO"' EXIT
# checar <nome> <status-esperado> <substring-no-corpo> -- <curl...>
checar() {
local nome="$1" esperado="$2" trecho="$3"
shift 3
[ "${1:-}" = "--" ] && shift
local status
status="$(curl -s -o "$CORPO" -w '%{http_code}' "$@" || echo 000)"
local corpo
corpo="$(cat "$CORPO")"
if [ "$status" != "$esperado" ]; then
printf 'FALHOU %-34s status=%s (esperado %s)\n' "$nome" "$status" "$esperado"
printf ' corpo: %s\n' "${corpo:0:200}"
FALHAS=$((FALHAS + 1))
return 1
fi
if [ -n "$trecho" ] && ! printf '%s' "$corpo" | grep -q "$trecho"; then
printf 'FALHOU %-34s status ok, mas sem "%s" no corpo\n' "$nome" "$trecho"
printf ' corpo: %s\n' "${corpo:0:200}"
FALHAS=$((FALHAS + 1))
return 1
fi
printf 'ok %-34s %s\n' "$nome" "$status"
return 0
}
echo "── dublê de SaaS do e2e em $BASE ──"
# ── Plano de controle ──
checar "saúde" 200 '"ok":true' "$BASE/__duble/saude"
curl -s -X DELETE "$BASE/__duble/recebidos" >/dev/null
# ── Resend ──
# Sem chave o SaaS real devolve 401: dublê permissivo esconderia do teste que o
# produto parou de mandar o `Authorization`.
checar "resend: sem chave → 401" 401 'missing_api_key' \
-X POST "$BASE/emails" -H 'content-type: application/json' -d '{"to":"qa@deskcomm.test"}'
checar "resend: POST /emails" 200 '"id"' \
-X POST "$BASE/emails" \
-H "authorization: Bearer $CHAVE_RESEND" -H 'content-type: application/json' \
-d '{"from":"qa@deskcomm.test","to":"dono@qa.local","subject":"convite","html":"<p>oi</p>"}'
ID_EMAIL="$(sed -n 's/.*"id":"\([^"]*\)".*/\1/p' "$CORPO")"
checar "resend: GET /emails/:id" 200 '"last_event"' \
"$BASE/emails/$ID_EMAIL"
# ── Nuvemshop: handshake OAuth ──
# O 302 com `code` e `state` na query é o passo que o teste de instalação
# exercita; sem `-L` de propósito, porque o que se cobra é o redirecionamento.
STATUS_REDIR="$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/apps/e2e-app-id/authorize?client_id=e2e-app-id&state=xyz&redirect_uri=https%3A%2F%2Fqa.local%2Fapi%2Fnuvemshop%2Fcallback")"
LOCAL="$(curl -s -D - -o /dev/null \
"$BASE/apps/e2e-app-id/authorize?client_id=e2e-app-id&state=xyz&redirect_uri=https%3A%2F%2Fqa.local%2Fapi%2Fnuvemshop%2Fcallback" \
| tr -d '\r' | sed -n 's/^[Ll]ocation: //p')"
if [ "$STATUS_REDIR" = "302" ] && printf '%s' "$LOCAL" | grep -q 'code=' && printf '%s' "$LOCAL" | grep -q 'state=xyz'; then
printf 'ok %-34s 302 → %s\n' "nuvemshop: authorize (OAuth)" "$LOCAL"
else
printf 'FALHOU %-34s status=%s location=%s\n' "nuvemshop: authorize (OAuth)" "$STATUS_REDIR" "$LOCAL"
FALHAS=$((FALHAS + 1))
fi
checar "nuvemshop: authorize sem redirect" 400 'redirect_uri' \
"$BASE/apps/e2e-app-id/authorize?state=sem-redirect"
checar "nuvemshop: token (segredo errado)" 401 'invalid_client' \
-X POST "$BASE/apps/authorize/token" -H 'content-type: application/json' \
-d '{"client_id":"e2e-app-id","client_secret":"errado","code":"c"}'
checar "nuvemshop: token (handshake)" 200 '"access_token"' \
-X POST "$BASE/apps/authorize/token" -H 'content-type: application/json' \
-d "{\"client_id\":\"e2e-app-id\",\"client_secret\":\"$SEGREDO_NUVEMSHOP\",\"code\":\"codigo-e2e\"}"
# ── Nuvemshop: API autenticada ──
checar "nuvemshop: GET /:store_id/store" 200 '"Loja E2E"' \
"$BASE/123456/store" -H "authorization: Bearer token-e2e"
checar "nuvemshop: POST /webhooks" 201 '"order/paid"' \
-X POST "$BASE/123456/webhooks" -H 'content-type: application/json' \
-d '{"event":"order/paid","url":"https://qa.local/api/nuvemshop/webhook"}'
checar "nuvemshop: GET /webhooks" 200 'order/paid' \
"$BASE/123456/webhooks"
checar "nuvemshop: DELETE /webhooks" 204 '' \
-X DELETE "$BASE/123456/webhooks/1001"
# ── Registro ──
# É o que permite ao teste afirmar "o e-mail saiu" e "o webhook foi registrado"
# sem mock em processo.
checar "registro de recebidos" 200 '"total"' "$BASE/__duble/recebidos"
echo " requisições registradas: $(cat "$CORPO" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')"
# ── Rota desconhecida ──
checar "rota desconhecida → 404" 404 'rota_desconhecida' "$BASE/nao-existe"
if [ "$FALHAS" -ne 0 ]; then
echo "── $FALHAS de 13 verificações FALHARAM ──"
exit 1
fi
echo "── 13 de 13 verificações passaram ──"
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env node
/**
* Dublê HTTP dos SaaS que o e2e não pode alcançar de verdade (Resend e
* Nuvemshop). Issue #179.
*
* ── POR QUE UM SERVIDOR DE VERDADE, E NÃO UM MOCK EM PROCESSO ─────────────
*
* O produto fala com esses dois por HTTP, de dentro do processo do servidor
* Next (`next start`), não de dentro do teste. Um `vi.mock()`/intercept de
* `fetch` viveria no processo do PLAYWRIGHT e o servidor sob teste continuaria
* batendo na internet: o teste passaria a medir o dublê, não o produto. A
* doutrina de QA visual do CLAUDE.md pede a mesma coisa em outras palavras —
* efeito colateral externo se prova com receptor real, não com mock em
* processo.
*
* Por isso este arquivo é um servidor HTTP de verdade: sobe, escuta numa
* porta, responde o que o SaaS responderia, e GUARDA o que recebeu para que o
* teste possa afirmar depois ("o e-mail saiu?", "o webhook foi registrado?").
*
* ── O QUE ELE DUBLA ───────────────────────────────────────────────────────
*
* Resend (`https://api.resend.com`):
* POST /emails → 200 { id }
* GET /emails/:id → 200 { id, to, subject, last_event }
*
* Nuvemshop / Tiendanube (`https://www.tiendanube.com` e
* `https://api.tiendanube.com/v1`), incluindo o handshake OAuth:
* GET /apps/:app_id/authorize → 302 no `redirect_uri` com `code` e `state`
* POST /apps/authorize/token → { access_token, token_type, scope, user_id }
* GET /:store_id/store → dados da loja
* GET /:store_id/webhooks → lista
* POST /:store_id/webhooks → cria (id sintético)
* DELETE /:store_id/webhooks/:id → 204
*
* Plano de controle (não existe no SaaS, existe para o teste):
* GET /__duble/saude → 200 { ok, resend, nuvemshop, porta }
* GET /__duble/recebidos → 200 { total, requisicoes: [...] }
* DELETE /__duble/recebidos → 204 (zera a caixa)
*
* ── COMO O APP CHEGA AQUI ────────────────────────────────────────────────
*
* Pela env, nunca por edição de código:
* RESEND_API_BASE_URL (default do produto: https://api.resend.com)
* NUVEMSHOP_AUTH_BASE (default: https://www.tiendanube.com)
* NUVEMSHOP_API_BASE (default: https://api.tiendanube.com/v1)
* O `.env.e2e` é quem carrega esses valores, e ele só é aceito em localhost —
* a guarda está em `playwright.config.ts` e não foi tocada.
*
* Quando a env não é setada, os defaults do produto valem e este servidor não
* é alcançado: é isso que mantém a produção fora do caminho.
*
* Zero dependências (só `node:http`/`node:url`): o passo do CI não pode
* depender de `pnpm install` ter dado certo para conseguir subir o dublê.
*
* Uso: node scripts/duble-saas-e2e.mjs [--porta 3997] [--host 127.0.0.1]
*/
import http from "node:http";
import { URL } from "node:url";
// ── Configuração ───────────────────────────────────────────────────────────
/**
* Porta 3997: vizinha de 3998 (Redis HTTP) e 3999 (WAHA), que o
* `scripts/gerar-env-e2e.sh` já fixa. As três cabem na mesma régua.
*/
const PORTA = Number(process.env.DUBLE_SAAS_PORTA ?? 3997);
const HOST = process.env.DUBLE_SAAS_HOST ?? "127.0.0.1";
const APP_ID_NUVEMSHOP = process.env.NUVEMSHOP_APP_ID ?? "e2e-app-id";
const CLIENT_SECRET_NUVEMSHOP =
process.env.NUVEMSHOP_CLIENT_SECRET ?? "e2e-placeholder-nao-e-segredo";
/**
* Teto da caixa de entrada. Um teste que dispara e-mail em laço não pode
* derrubar o runner por memória; 500 é folgado para as specs do repo, que
* mandam unidades por cenário.
*/
const TETO_RECEBIDOS = 500;
// ── Estado (em memória, por processo) ──────────────────────────────────────
/** @type {Array<{t: string, metodo: string, caminho: string, corpo: unknown}>} */
const recebidos = [];
/** @type {Map<string, {id: string, to: unknown, subject: string, html: string, last_event: string}>} */
const emails = new Map();
/** @type {Map<number, {id: number, event: string, url: string}>} */
const webhooks = new Map();
let seq = 0;
function registrar(metodo, caminho, corpo) {
recebidos.push({ t: new Date().toISOString(), metodo, caminho, corpo });
if (recebidos.length > TETO_RECEBIDOS) recebidos.shift();
}
function proximoId(prefixo) {
seq += 1;
return `${prefixo}_${Date.now().toString(36)}${seq.toString(36)}`;
}
// ── Utilidades HTTP ────────────────────────────────────────────────────────
async function lerCorpo(req) {
const pedacos = [];
for await (const p of req) pedacos.push(p);
const cru = Buffer.concat(pedacos).toString("utf8");
if (!cru) return undefined;
try {
return JSON.parse(cru);
} catch {
// Corpo não-JSON é guardado como está: é sinal de que o produto mudou o
// formato, e o teste merece ver isso em vez de um `undefined` silencioso.
return cru;
}
}
function responder(res, status, corpo, extras = {}) {
const cabecalhos = { "content-type": "application/json", ...extras };
const texto = corpo === undefined ? "" : JSON.stringify(corpo);
res.writeHead(status, cabecalhos);
res.end(texto);
}
/** O `Bearer` que o Resend usa. Sem chave, o SaaS real devolve 401. */
function autorizadoResend(req) {
const cabecalho = req.headers.authorization ?? "";
return /^Bearer\s+\S+/i.test(cabecalho);
}
// ── Rotas: Resend ──────────────────────────────────────────────────────────
function resendCriarEmail(req, corpo, res) {
if (!autorizadoResend(req)) {
// 401 de verdade: um dublê permissivo esconderia do teste que o produto
// parou de mandar a chave.
return responder(res, 401, { statusCode: 401, name: "missing_api_key", message: "Missing API key" });
}
const id = proximoId("email");
const registro = {
id,
to: corpo?.to ?? null,
subject: corpo?.subject ?? "",
html: corpo?.html ?? "",
last_event: "delivered",
};
emails.set(id, registro);
return responder(res, 200, { id });
}
// ── Rotas: Nuvemshop / Tiendanube ──────────────────────────────────────────
function nuvemshopAuthorize(url, res) {
const estado = url.searchParams.get("state") ?? "sem-state";
const redirecionar = url.searchParams.get("redirect_uri");
const codigo = `codigo-e2e-${estado.slice(0, 12)}`;
if (!redirecionar) {
// Sem `redirect_uri` não há para onde voltar: 400 explícito, porque um 302
// para lugar nenhum viraria "timeout misterioso" no teste.
return responder(res, 400, {
error: "invalid_request",
error_description: "redirect_uri ausente",
});
}
let destino;
try {
destino = new URL(redirecionar);
} catch {
return responder(res, 400, { error: "invalid_request", error_description: "redirect_uri inválido" });
}
destino.searchParams.set("code", codigo);
destino.searchParams.set("state", estado);
// 302 é o que o SaaS faz: o browser do lojista é jogado de volta no app com
// o `code` na query. É este passo que o teste de instalação exercita.
res.writeHead(302, { location: destino.toString() });
res.end();
}
function nuvemshopToken(corpo, res) {
if (!corpo || typeof corpo !== "object") {
return responder(res, 400, { error: "invalid_request", error_description: "corpo ausente" });
}
if (corpo.client_secret !== CLIENT_SECRET_NUVEMSHOP) {
return responder(res, 401, { error: "invalid_client", error_description: "client_secret inválido" });
}
return responder(res, 200, {
access_token: `token-e2e-${cx()}`,
token_type: "bearer",
scope: "read_products,write_products,read_orders",
user_id: 42,
});
}
/** O código do token carrega o app_id e o store_id do corpo: dois testes no
* mesmo processo não podem compartilhar credencial por acidente. */
function cx() {
return `${APP_ID_NUVEMSHOP}-${Date.now().toString(36)}`;
}
function nuvemshopWebhooks(metodo, partes, corpo, res) {
if (metodo === "GET") {
return responder(res, 200, [...webhooks.values()]);
}
if (metodo === "POST") {
const id = 1000 + webhooks.size + 1;
const registro = {
id,
event: String(corpo?.event ?? "order/paid"),
url: String(corpo?.url ?? ""),
};
webhooks.set(id, registro);
return responder(res, 201, registro);
}
if (metodo === "DELETE") {
const id = Number(partes[partes.length - 1]);
webhooks.delete(id);
return responder(res, 204, undefined);
}
return responder(res, 405, { error: "method_not_allowed" });
}
function nuvemshopLoja(storeId, res) {
return responder(res, 200, {
id: Number(storeId),
name: "Loja E2E",
email: "loja-e2e@deskcomm.test",
country: "BR",
currency: "BRL",
language: "pt",
domain: "loja-e2e.example",
});
}
// ── Roteador ───────────────────────────────────────────────────────────────
async function rotear(req, res) {
const url = new URL(req.url ?? "/", `http://${HOST}:${PORTA}`);
const metodo = req.method ?? "GET";
const partes = url.pathname.split("/").filter(Boolean);
const corpo = ["POST", "PUT", "PATCH"].includes(metodo) ? await lerCorpo(req) : undefined;
if (!partes[0]?.startsWith("__duble")) {
registrar(metodo, url.pathname + url.search, corpo);
}
// ── Plano de controle ──
if (partes[0] === "__duble") {
if (partes[1] === "saude") {
return responder(res, 200, {
ok: true,
porta: PORTA,
recebidos: recebidos.length,
emails: emails.size,
webhooks: webhooks.size,
resend: "POST /emails · GET /emails/:id",
nuvemshop: "GET /apps/:id/authorize · POST /apps/authorize/token · /:store_id/{store,webhooks}",
});
}
if (partes[1] === "recebidos") {
if (metodo === "DELETE") {
recebidos.length = 0;
return responder(res, 204, undefined);
}
return responder(res, 200, { total: recebidos.length, requisicoes: recebidos });
}
return responder(res, 404, { error: "rota_de_controle_desconhecida" });
}
// ── Resend ──
if (partes[0] === "emails") {
// `req` vai junto: `autorizadoResend` lê o `Authorization` do cabeçalho, e
// sem ele o 401 de verdade (chave ausente) virava 500 — o dublê escondia do
// teste justamente o que ele existe para mostrar.
if (metodo === "POST") return resendCriarEmail(req, corpo, res);
if (metodo === "GET" && partes[1]) {
const email = emails.get(partes[1]);
if (!email) return responder(res, 404, { statusCode: 404, name: "not_found", message: "Email not found" });
return responder(res, 200, email);
}
return responder(res, 405, { error: "method_not_allowed" });
}
// ── Nuvemshop: handshake OAuth ──
if (partes[0] === "apps") {
// /apps/:app_id/authorize — pode vir com prefixo de idioma (`/pt/`), como o
// SaaS real serve; por isso a busca é por sufixo e não por índice fixo.
if (metodo === "GET" && partes[partes.length - 1] === "authorize") {
return nuvemshopAuthorize(url, res);
}
if (metodo === "POST" && partes[1] === "authorize" && partes[2] === "token") {
return nuvemshopToken(corpo, res);
}
return responder(res, 404, { error: "rota_nuvemshop_desconhecida", caminho: url.pathname });
}
// ── Nuvemshop: API autenticada — /:store_id/{store,webhooks} ──
if (partes.length >= 2 && /^\d+$/.test(partes[0])) {
const storeId = partes[0];
if (partes[1] === "store" && metodo === "GET") return nuvemshopLoja(storeId, res);
if (partes[1] === "webhooks") return nuvemshopWebhooks(metodo, partes, corpo, res);
return responder(res, 404, { error: "rota_nuvemshop_desconhecida", caminho: url.pathname });
}
return responder(res, 404, { error: "rota_desconhecida", caminho: url.pathname });
}
const servidor = http.createServer((req, res) => {
// `res.req` é usado por `autorizadoResend`; em node >= 18 já existe, mas
// fixar aqui deixa a dependência explícita em vez de implícita.
res.req = req;
rotear(req, res).catch((erro) => {
responder(res, 500, { error: "erro_no_duble", detalhe: String(erro?.message ?? erro) });
});
});
servidor.listen(PORTA, HOST, () => {
const base = `http://${HOST}:${PORTA}`;
console.log(`[duble-saas] ouvindo em ${base}`);
console.log(`[duble-saas] resend → RESEND_API_BASE_URL=${base}`);
console.log(`[duble-saas] nuvemshop → NUVEMSHOP_AUTH_BASE=${base}`);
console.log(`[duble-saas] nuvemshop → NUVEMSHOP_API_BASE=${base}`);
console.log(`[duble-saas] saúde → ${base}/__duble/saude`);
});
for (const sinal of ["SIGINT", "SIGTERM"]) {
process.on(sinal, () => {
servidor.close(() => process.exit(0));
});
}
+36 -1
View File
@@ -127,7 +127,7 @@ cat > .env.e2e <<EOF
NEXT_PUBLIC_SUPABASE_URL=$API_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON
SUPABASE_SERVICE_ROLE_KEY=$SERVICE
# Vem do stack que está de pé (ver o comentário do `ler DB_URL` acima), não de
# Vem do stack que está de pé (ver o comentário do \`ler DB_URL\` acima), não de
# um literal: é isto que mantém duas sessões locais escrevendo cada uma no seu
# banco.
SUPABASE_DB_URL=$DB_URL
@@ -161,6 +161,41 @@ WAHA_API_KEY=e2e-placeholder-nao-e-segredo
WAHA_WEBHOOK_BASE_URL=http://127.0.0.1:3001
UPSTASH_REDIS_REST_URL=http://127.0.0.1:3998
UPSTASH_REDIS_REST_TOKEN=e2e-placeholder-nao-e-segredo
# ── O DONO DA INSTALAÇÃO — o primeiro usuário, como o \`install.sh\` cria ────
# A \`vps-fresh-onboarding\` (parte 4 do CI) roda numa instalação onde NINGUÉM
# existe ainda: o \`install.sh\` de uma VPS recém-instalada cria o primeiro dono
# com \`scripts/bootstrap-owner.ts\`, e a spec exige isso como PRECONDIÇÃO
# (cabeçalho dela) — sem esses valores o \`beforeAll\` para em "nao achou o dono
# (dono@qa.local)", porque a spec é destrutiva e se recusa a escolher a
# organização no escuro.
#
# Ficam AQUI, e não redigitados no workflow, porque este arquivo é a fonte
# única do ambiente da suíte: o passo "Publicar o .env.e2e no ambiente do job"
# o leva inteiro para o job, então o CI e quem roda local leem o MESMO dono.
# Mesmos valores de \`docs/testing/HANDOFF-vps-qa.md\` (a receita local da
# jornada) e do cabeçalho da spec.
#
# Backtick escapado neste heredoc não é estilo: ele é \`<<EOF\` sem aspas, então
# crase crua vira SUBSTITUIÇÃO DE COMANDO — o comentário chega no arquivo
# mutilado e o shell imprime "command not found" no log do CI.
#
# ⚠️ E nenhum valor daqui pode ter ESPAÇO — são dois consumidores que não
# combinam entre si:
# 1. o \`e2e-build.sh\` carrega o arquivo com \`set -a; . ./.env.e2e\`. Valor com
# espaço faz o shell ler o resto como COMANDO: \`OWNER_ORG_NAME=Loja QA VPS\`
# imprime \`QA: command not found\` e o build morre — medido em 2026-09-16,
# com as QUATRO partes do e2e vermelhas por causa desta linha;
# 2. o passo "Publicar o .env.e2e no ambiente do job" copia as linhas LITERAIS
# para o \`\$GITHUB_ENV\`, que NÃO é shell. Então aspas não resolvem: elas
# entrariam no valor e a organização nasceria chamada \"Loja QA VPS\", com
# aspas no nome.
# Nome de organização aqui é um token só. A guarda que cobra isso está em
# \`tests/unit/e2e-cria-o-dono-que-a-spec-exige.test.ts\` (carrega o arquivo).
OWNER_EMAIL=dono@qa.local
OWNER_PASSWORD=QaVps!2026#Dono
OWNER_ORG_NAME=Loja-QA-VPS
NEXT_TELEMETRY_DISABLED=1
# Telemetria DESLIGADA na suíte, e não é preferência: sem isto o SDK do browser
# assume o DSN da comunidade (\`lib/sentry/dsn.ts\` → DEFAULT_SENTRY_DSN) e a suíte
+185 -59
View File
@@ -5,6 +5,8 @@
* - banco zerado do baseline.sql (Supabase local pg17)
* - primeiro usuário criado via scripts/bootstrap-owner.ts (como o install.sh)
* - WAHA ativo, Redis local, RESEND_API_KEY VAZIO (realidade da VPS fresca)
* - SEM chave de IA na instalação (o install.sh deixa pular com Enter; o
* .env.e2e não traz nenhuma) — J1.7 e J1.24 afirmam o agente em rascunho
* - app em produção (next build + next start) na E2E_PORT
*
* Casos: J1.1–J1.13 do docs/testing/user-journey-map.md. Tudo pelo frontend;
@@ -16,6 +18,8 @@ import * as path from "node:path";
import { test, expect, type Page } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import { PROVEDOR_POR_ID } from "@/lib/ai/pontos/provedores";
import { generateTotp, msUntilNextTotpWindow } from "./utils/totp";
const OWNER_EMAIL = "dono@qa.local";
@@ -224,49 +228,27 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
await snap(page, "j1.6-setup-ai");
});
test("J1.7 setup IA: cria agente default e avança", async ({ page }) => {
test("J1.7 setup IA sem chave: cria o agente como rascunho, diz o que falta e deixa seguir", async ({ page }) => {
// ⚠️ ESTE CASO MUDOU DE DESFECHO, e a razão é o ambiente, não o produto.
// Ele afirmava "cria, PUBLICA e vai para /onboarding/testar" — o que só é
// verdade numa instalação que já tem chave de IA. O `install.sh` deixa pular
// a chave com Enter, e o `.env.e2e` (o ambiente desta suíte, local e CI) não
// traz nenhuma. Medido no run 35150134046 (parte 4 do PR #983, a primeira
// vez que esta spec rodou no CI): o clique em "Criar e continuar" devolve
// `publish_blocked_by: "chave"`, a tela mostra o aviso de rascunho com
// "Continuar sem publicar", e o `waitForURL(/testar/)` estourou 20s parado
// nesse aviso. O J1.24 logo abaixo já afirmava "rascunho" na tela de testar
// — os dois casos descreviam instalações diferentes.
await login(page);
await page.waitForURL(/\/onboarding\/setup-ai/);
await page.locator("#name").fill("Tomik QA");
await page.getByRole("button", { name: /criar e continuar/i }).click();
// O wizard ganhou um passo entre treinar e chamar o time: ver o
// funcionário atender. Terminar sem nunca tê-lo visto fazer nada era como
// o onboarding entregava a pessoa num inbox vazio.
await page.waitForURL(/\/onboarding\/testar/, { timeout: 20_000 });
await snap(page, "j1.7-testar");
// `eq(organization_id)` pela MESMA razão de `orgRow()` acima: sem ele, este
// `select` lê os agentes de TODAS as organizações do banco, e o
// `expect(length).toBe(1)` deixa de medir "o wizard criou um agente" e passa
// a medir "o banco inteiro tem um agente" — que é falso em qualquer
// instalação com uso, e vermelho por motivo que não é o desta jornada.
// `select` lê de TODAS as organizações do banco, e as asserções deixam de
// medir a instalação que o wizard acabou de configurar.
const orgDoDono = await orgRow();
const { data: agents } = await svc
.from("ai_agents")
.select("id, name, is_active, is_default, published_version_id")
.eq("organization_id", orgDoDono.id);
expect(agents?.length).toBe(1);
expect(agents?.[0]).toMatchObject({ name: "Tomik QA", is_active: true, is_default: true });
// A VERSÃO, e não só o agente. Este caso olhava apenas `ai_agents` — e foi
// por isso que a regressão do provedor nasceu invisível: o agente ficava
// bonito na tabela enquanto a versão publicada apontava para uma empresa de
// IA que a instalação não contratou, morrendo em toda mensagem.
const { data: versoes } = await svc
.from("ai_agent_versions")
.select("provider, model, status, channel_session_id")
.eq("agent_id", agents?.[0]?.id ?? "");
expect(versoes?.length).toBe(1);
expect(versoes?.[0]?.status).toBe("published");
// E o provedor da versão é o MESMO que a instalação escolheu. Comparar com
// uma string fixa aqui não provaria nada: o teste passaria justamente na
// instalação Anthropic, que é a única em que o defeito não aparecia.
// A SEGUNDA instância de "a primeira organização" neste mesmo arquivo. Aqui
// ela não apaga nada — faz pior de um jeito silencioso: `escolhido` vira o
// provedor de OUTRA organização, e a asserção abaixo passa ou reprova sem
// relação com a instalação que o wizard acabou de configurar.
const { data: org } = await svc
.from("organizations")
.select("settings")
@@ -274,18 +256,98 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
.maybeSingle();
const escolhido =
(org?.settings as { llm?: { provider?: string } } | null)?.llm?.provider ?? "anthropic";
expect(versoes?.[0]?.provider).toBe(escolhido);
// O modelo veio do catálogo DAQUELE provedor — nunca um id emprestado.
const { data: curado } = await svc
.from("ai_models")
.select("model_id")
.eq("provider", escolhido)
.eq("is_default_for_provider", true)
.is("deprecated_at", null)
.limit(1)
// O aviso nomeia a empresa de IA que a INSTALAÇÃO escolheu. É o que sobra,
// sem chave, da guarda da regressão do provedor: o passo publicava
// "anthropic" literal para quem tinha escolhido outra, e o `provider` deste
// aviso sai da mesma leitura de `settings.llm.provider` que a versão usaria.
// Comparar com uma string fixa não provaria nada — passaria justamente na
// instalação Anthropic, a única em que o defeito não aparecia.
const aviso = page.getByRole("alert").filter({ hasText: /rascunho/i });
await expect(aviso).toBeVisible({ timeout: 20_000 });
await expect(aviso).toContainText(PROVEDOR_POR_ID.get(escolhido)?.rotulo ?? escolhido);
await snap(page, "j1.7-sem-chave-rascunho");
// Sem esta saída o passo é um beco: o diagnóstico está certo e nenhum botão.
await aviso.getByRole("button", { name: /continuar sem publicar/i }).click();
// Depois de treinar vem "Onde ele organiza" (J1.26, `/onboarding/funil`),
// e só então "Ver ele atender". Medido no run 35401941259 (parte 4): o
// clique avançou para `/onboarding/funil` e este `waitForURL` esperava
// `/testar`, o passo seguinte. O produto seguiu; a spec é que pulava um passo.
await page.waitForURL(/\/onboarding\/funil/, { timeout: 20_000 });
await snap(page, "j1.7-funil");
const { data: agents } = await svc
.from("ai_agents")
.select("id, name, is_active, is_default, published_version_id")
.eq("organization_id", orgDoDono.id);
expect(agents?.length).toBe(1);
expect(agents?.[0]).toMatchObject({
name: "Tomik QA",
is_active: true,
is_default: true,
published_version_id: null,
});
// A VERSÃO, e não só o agente: sem chave utilizável nenhuma é gravada. Uma
// versão "publicada" aqui seria o agente que morre em toda mensagem pedindo
// uma chave que a instalação nunca teve.
const { data: versoes } = await svc
.from("ai_agent_versions")
.select("id")
.eq("agent_id", agents?.[0]?.id ?? "");
expect(versoes?.length).toBe(0);
// O passo aconteceu mesmo sem publicar — é o que faz o wizard seguir em
// vez de reabrir "Treine seu funcionário".
const depois = await orgRow();
expect(
(depois.onboarding_state as { ai?: { agent_id?: string } } | null)?.ai?.agent_id,
).toBe(agents?.[0]?.id);
});
test("J1.26 onde ele organiza: sem funcionário no ar, oferece um quadro pronto e deixa seguir", async ({ page }) => {
// Numa instalação sem chave de IA o agente ficou rascunho (J1.7), então a
// sugestão de quadro, que sai do MESMO modelo que vai atender, não tem a
// quem pedir. O passo não pode virar beco: diz o porquê, começa de um
// modelo pronto e deixa seguir.
await login(page);
await page.waitForURL(/\/onboarding\/funil/, { timeout: 20_000 });
await expect(page.getByRole("heading", { name: /onde ele organiza seus clientes/i })).toBeVisible();
await expect(page.getByText(/ainda não está no ar/i)).toBeVisible();
await expect(page.getByText(/isso não trava nada/i)).toBeVisible();
// O que a tela mostra é o que tem de ser gravado: lido da própria tela, não
// de uma lista fixa, para o caso valer com qualquer modelo pronto.
const nomeDoQuadro = await page.getByLabel("Nome do quadro").inputValue();
const colunas = await page.getByLabel(/^Nome da coluna \d+$/).evaluateAll((els) =>
els.map((e) => (e as HTMLInputElement).value),
);
expect(nomeDoQuadro.trim()).not.toBe("");
expect(colunas.length).toBeGreaterThan(0);
await snap(page, "j1.26-funil-sem-ia");
await page.getByRole("button", { name: /usar este quadro/i }).click();
await page.waitForURL(/\/onboarding\/testar/, { timeout: 20_000 });
const org = await orgRow();
const funil = (org.onboarding_state as { funil?: { pipeline_id?: string } } | null)?.funil;
expect(funil?.pipeline_id, "o passo do quadro ficou registrado").toBeTruthy();
const { data: pipeline } = await svc
.from("crm_pipelines")
.select("name")
.eq("organization_id", org.id)
.eq("id", funil?.pipeline_id ?? "")
.maybeSingle();
expect(versoes?.[0]?.model).toBe(curado?.model_id);
expect(pipeline?.name).toBe(nomeDoQuadro.trim());
const { data: etapas } = await svc
.from("crm_stages")
.select("name")
.eq("organization_id", org.id)
.eq("pipeline_id", funil?.pipeline_id ?? "");
for (const coluna of colunas) {
expect(etapas?.map((e) => e.name), `a coluna "${coluna}" da tela foi gravada`).toContain(coluna.trim());
}
});
test("J1.24 ver ele atender: o wizard não termina sem mostrar o funcionário", async ({ page }) => {
@@ -297,10 +359,21 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
await page.waitForURL(/\/onboarding\/testar/, { timeout: 20_000 });
await expect(page.getByRole("heading", { name: /veja ele atender/i })).toBeVisible();
// O agente desta jornada nasceu SEM canal (o WhatsApp foi pulado em J1.6),
// então ficou rascunho — e rascunho não responde. A tela tem de dizer isso
// em vez de oferecer um ensaio que nunca funcionaria.
await expect(page.getByText(/rascunho/i)).toBeVisible();
// O agente desta jornada ficou rascunho — sem versão, porque a instalação
// não tem chave de IA (ver J1.7; o canal existe desde o QR de J1.5) — e
// rascunho não responde. A tela tem de dizer isso em vez de oferecer um
// ensaio que nunca funcionaria.
//
// A asserção mora no aviso (`role="status"`), e não em "a palavra aparece
// em algum lugar da página": no run 35407985023 o `getByText(/rascunho/i)`
// casou DOIS nós — o `<strong>` da frase e o parágrafo que explica —, os
// dois certos, e o strict mode reprovou a sonda, não o produto. Prender o
// aviso e exigir as DUAS frases é mais estreito do que era antes: diz o
// ESTADO (não foi para o ar) e a CONSEQUÊNCIA (não há o que ensaiar).
const aviso = page.getByRole("status").filter({ hasText: /rascunho/i });
await expect(aviso).toBeVisible();
await expect(aviso).toContainText(/ainda não foi para o ar/i);
await expect(aviso).toContainText(/não responde mensagem/i);
await snap(page, "j1.24-testar-rascunho");
await page.getByRole("button", { name: /^continuar$/i }).click();
@@ -319,9 +392,22 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
// Honestidade: sem RESEND_API_KEY nenhum email sai. A UI deve dizer isso
// e oferecer o link de aceite copiável (nunca redirecionar em silêncio).
await expect(page.getByText(/não está configurado neste servidor/i)).toBeVisible({
//
// A frase que este caso procurava ("não está configurado neste servidor")
// não existe mais no produto — `git grep` devolve zero. O texto de hoje é
// o do bloco âmbar de `app/onboarding/invite-team/_form.tsx:109`, e a tela
// ainda diz a verdade: medido no job 105816595263 (parte 4), ela mostra
// "Esta instalação não envia e-mail" com o link e o botão de copiar.
await expect(page.getByText(/não envia e-mail/i).first()).toBeVisible({
timeout: 15_000,
});
// O nome deste caso é "a UI não pode MENTIR que enviou": o controle
// negativo é o que o torna verdade, e ele faltava. Nenhuma frase de envio
// bem-sucedido pode aparecer numa instalação sem serviço de e-mail.
await expect(page.getByText(/convites? enviad/i)).toHaveCount(0);
// E a pessoa convidada aparece nominalmente ao lado do link dela, senão
// "copie o link" não diz de quem é o link.
await expect(page.getByText("atendente@qa.local").first()).toBeVisible();
const acceptUrl = (
await page.locator("code", { hasText: /team\/accept-invite/ }).first().innerText()
).trim();
@@ -366,9 +452,14 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
await expect(page.getByText("Desativada")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: /^ativar$/i }).click();
await expect(
page.getByRole("heading", { name: /verificação em duas etapas/i }),
).toBeVisible({ timeout: 20_000 });
// O título do DIÁLOGO, e não "o texto aparece em algum lugar": a página de
// Segurança tem a seção "Verificação em duas etapas" e o diálogo tem
// "Configure a verificação em duas etapas". Medido no run da parte 4 (job
// 105825863584): o `getByRole('heading', /verificação em duas etapas/i)`
// casava os DOIS e o strict mode reprovava — os dois certos, a sonda é que
// não dizia qual. Prender no `#mfa-title` é o que prova que o diálogo ABRIU.
await expect(page.locator("#mfa-title")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#mfa-title")).toHaveText(/verificação em duas etapas/i);
await snap(page, "j1.10-mfa-ativar");
await page.getByRole("button", { name: /iniciar configuração/i }).click();
@@ -391,7 +482,19 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
await page.locator('input[aria-label="Dígito 1"]').click();
await page.keyboard.type(generateTotp(secret), { delay: 40 });
try {
await expect(page.getByRole("heading", { name: /códigos de recuperação/i })).toBeVisible({
// MESMA ARMADILHA DO FECHO, e aqui ela desligava o retry: a página de
// Segurança tem a seção "Códigos de recuperação" impressa desde antes
// do enroll (`_client.tsx:176`, fora de condicional), então
// `getByRole('heading', /códigos de recuperação/i)` já valia ANTES de
// o modal chegar ao passo dos códigos — e passava na hora, mesmo com o
// TOTP recusado. Medido: com os dois títulos no DOM o strict mode
// reprova (`resolved to 2 elements`), então o verde só podia vir do
// casamento único, o da página. Resultado: este `for` nunca dava a
// segunda volta e a virada da janela TOTP caía lá embaixo, como falha
// confusa. `#mfa-title` é o título do passo ATUAL do modal (intro,
// scan e codes são ramos exclusivos), então prendê-lo aqui é o que
// pergunta de fato "o modal avançou?".
await expect(page.locator("#mfa-title")).toHaveText(/códigos de recuperação/i, {
timeout: 8_000,
});
break;
@@ -416,12 +519,35 @@ test.describe("J1 — onboarding do dono numa instalação fresca", () => {
await page.getByText(/salvei meus códigos/i).click();
await page.getByRole("button", { name: /^concluir$/i }).click();
// gate some após reload; shell do app visível
// ⚠️ SEGUNDA HERANÇA DA MESMA MUDANÇA DE PORTA. Cobrar que o título
// "Verificação em duas etapas" SUMA valia quando o cadastro vinha do
// bloqueador de tela cheia e terminar caía no inbox — daí o nome
// `j1.10-inbox-livre`. Hoje o fluxo começa e termina em Configurações ›
// Segurança, e essa página imprime a seção "Verificação em duas etapas"
// o tempo todo (`app/app/settings/security/_client.tsx:78`, fora de
// qualquer condicional). Medido no job 105829755207: `44 × locator
// resolved to 1 element` — o único casamento era essa seção, com o
// diálogo já fechado e o selo em "Ativada". O vermelho media a mudança de
// porta, não regressão.
//
// O que prova o fim do fluxo HOJE são duas coisas, e a segunda é a que
// não deixa o caso passar por acidente:
await page.waitForLoadState("networkidle");
await expect(
page.getByRole("heading", { name: /verificação em duas etapas/i }),
).toHaveCount(0, { timeout: 20_000 });
await snap(page, "j1.10-inbox-livre");
// 1) o DIÁLOGO fechou — `#mfa-title` é o título dos três passos do modal
// (intro, QR, códigos), então count 0 é o modal inteiro desmontado;
await expect(page.locator("#mfa-title")).toHaveCount(0, { timeout: 20_000 });
// 2) CONTROLE NEGATIVO — o estado MUDOU no servidor. Se o enroll não
// persistisse, ou se "Concluir" desfizesse o cadastro, a página
// recarregada voltaria exatamente ao estado em que este caso COMEÇOU
// (selo "Desativada" + botão "Ativar") e o modal também estaria
// fechado — ou seja, só a asserção (1) passaria feliz. Esta reprova.
await expect(page.getByText("Ativada", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /^desligar$/i })).toBeVisible();
await expect(page.getByText("Desativada", { exact: true })).toHaveCount(0);
// e a pessoa não ficou presa: o shell do app respondeu ao reload (se a
// sessão tivesse caído no enroll, aqui seria a tela de login).
await expect(page.getByRole("link", { name: "Inbox", exact: true })).toBeVisible();
await snap(page, "j1.10-verificacao-ativada");
});
test("J1.13 wizard não reabre depois de concluído", async ({ page }) => {
+2 -2
View File
@@ -12,8 +12,8 @@
* caso para descobrir que o problema era o tamanho da parte.
*
* O que este arquivo guarda é o PAR: a medição (relógio do job inteiro, não só
* o do Playwright — o preparo de ambiente custa ~9 min por parte, e o
* `Initialize containers` dos services custa ~51 s) e o aviso que acontece
* o do Playwright — o preparo de ambiente custa ~9 min por parte; até o #983,
* o `Initialize containers` dos services somava ~51 s a cada uma) e o aviso que acontece
* ANTES do corte, nomeando o problema com o número medido.
*
* ## Por que ler o YAML em vez de exercitar a rodada
+11 -4
View File
@@ -70,6 +70,11 @@ const yml = readFileSync(WORKFLOW, "utf8");
const parte1 = listaDoWorkflow(yml, "SPECS_PARTE_1");
const parte2 = listaDoWorkflow(yml, "SPECS_PARTE_2");
const parte3 = listaDoWorkflow(yml, "SPECS_PARTE_3");
// PARTE_4 — a parte que depende de serviço externo (WAHA + Redis + dublês de
// Resend/Nuvemshop, issue #179). Listada aqui como as outras: sem isto, a spec
// que roda SÓ ali apareceria como "sem lista" e o gate acusaria o contrário do
// que aconteceu.
const parte4 = listaDoWorkflow(yml, "SPECS_PARTE_4");
const foraDoCi = listaDoWorkflow(yml, "FORA_DO_CI");
const noDisco = readdirSync(DIR_SPECS)
.filter((f) => f.endsWith(".spec.ts"))
@@ -77,7 +82,7 @@ const noDisco = readdirSync(DIR_SPECS)
describe("cobertura do e2e no CI", () => {
it("o parser está vivo — controle positivo antes de qualquer conclusão", () => {
// Sem isto, um regex que parou de casar devolveria três listas vazias e a
// Sem isto, um regex que parou de casar devolveria listas vazias e a
// asserção de vigência passaria por vacuidade, enquanto a de completude
// acusaria as 39 specs de uma vez. Verde e vermelho errados pelo mesmo motivo.
expect(noDisco.length, "nenhuma spec no disco — o diretório mudou de lugar?").toBeGreaterThan(
@@ -86,16 +91,18 @@ describe("cobertura do e2e no CI", () => {
expect(parte1.length, "SPECS_PARTE_1 não foi lida do workflow").toBeGreaterThan(10);
expect(parte2.length, "SPECS_PARTE_2 não foi lida do workflow").toBeGreaterThan(10);
expect(parte3.length, "SPECS_PARTE_3 não foi lida do workflow").toBeGreaterThan(10);
expect(parte4.length, "SPECS_PARTE_4 não foi lida do workflow").toBeGreaterThan(0);
expect(foraDoCi.length, "FORA_DO_CI não foi lida do workflow").toBeGreaterThan(0);
});
it("toda spec do disco está em exatamente uma lista", () => {
const declaradas = [...parte1, ...parte2, ...parte3, ...foraDoCi];
const declaradas = [...parte1, ...parte2, ...parte3, ...parte4, ...foraDoCi];
const semLista = noDisco.filter((f) => !declaradas.includes(f));
expect(
semLista,
"Spec no disco que não roda no CI nem está declarada como fora. Ponha em " +
"SPECS_PARTE_1/2/3 (se rodar sem WAHA/Redis/Resend) ou em FORA_DO_CI com o " +
"SPECS_PARTE_1/2/3 (se rodar sem WAHA/Redis/Resend), em SPECS_PARTE_4 (com " +
"os serviços do job) ou em FORA_DO_CI com o " +
"motivo escrito. Cobertura parcial silenciosa se lê como cobertura total.\n",
).toEqual([]);
@@ -109,7 +116,7 @@ describe("cobertura do e2e no CI", () => {
// O sentido inverso, e ele é pior: `playwright test naoexiste.spec.ts` não
// acha nada e o job termina VERDE. Uma renomeação silenciosamente desliga a
// cobertura daquele arquivo.
const fantasmas = [...parte1, ...parte2, ...parte3, ...foraDoCi].filter(
const fantasmas = [...parte1, ...parte2, ...parte3, ...parte4, ...foraDoCi].filter(
(f) => !noDisco.includes(f),
);
expect(fantasmas, "lista do CI aponta para spec inexistente — renomeada ou apagada").toEqual(
@@ -0,0 +1,205 @@
/**
* A INSTALAÇÃO FRESCA NASCE VAZIA — E A SPEC QUE A EXERCITA NÃO PODE NASCER SEM DONO.
*
* ## O defeito
*
* A `vps-fresh-onboarding` (parte 4 do e2e) é a única spec que roda numa
* instalação de verdade fresca. O `beforeAll` dela resolve a organização do
* DONO do bootstrap (`dono@qa.local`) para resetá-la, e para ALTO se não achar:
* ela apaga `onboarding_state`, `ai_agents` e `channel_sessions` de quem
* resolver, então escolher "a primeira organização" já custou o onboarding e a
* sessão de WhatsApp de uma instalação real (medido em 2026-09-03).
*
* Essa precondição está escrita no cabeçalho da própria spec — "primeiro
* usuário criado via `scripts/bootstrap-owner.ts` (como o `install.sh`)" —
* junto de WAHA e Redis. Das três, era a única que o workflow não provia: ele
* subia WAHA, Redis e o dublê de SaaS, esperava o WAHA responder e entregava o
* turno ao Playwright. A parte 4 morria no `beforeAll`, antes do primeiro
* teste, com a mensagem que a spec escreveu exatamente para este caso.
*
* ## Por que uma guarda estática, e não "rodar o workflow"
*
* É acoplamento entre TRÊS arquivos — o workflow, o gerador do `.env.e2e` e a
* spec — e é decidível lendo os três, como o guard vizinho
* (`e2e-workflow-honra-o-env.test.ts`) já argumenta. O outro caminho é gastar
* um job de ~10 min de e2e (a parte 4 é a mais lenta das quatro) para descobrir
* o que uma leitura responde.
*
* ## O que se guarda
*
* Que o passo exista, que ele valha para a parte 4, que ele venha ANTES de a
* suíte rodar, e que as credenciais do dono continuem tendo UMA fonte — o
* `.env.e2e`, que o job publica — CONCORDANDO com o que a spec declara. Duas
* fontes que divergem é o defeito que já custou 8 specs em 401 neste mesmo
* workflow; duas fontes que concordam por coincidência é o mesmo defeito com
* data marcada.
*/
import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const RAIZ = path.resolve(__dirname, "../..");
const ler = (rel: string): string => fs.readFileSync(path.join(RAIZ, rel), "utf8");
const workflow = ler(".github/workflows/e2e.yml");
const gerador = ler("scripts/gerar-env-e2e.sh");
const spec = ler("tests/e2e/vps-fresh-onboarding.spec.ts");
/** Linhas de `run:` do workflow, na ordem em que o job as executa. */
const LINHAS = workflow.split("\n");
const COMANDOS = LINHAS.filter((l) => !l.trim().startsWith("#")).map((l) => l.trim());
function indiceDe(pred: (linha: string) => boolean): number {
return COMANDOS.findIndex(pred);
}
/** A declaração da constante na spec: é ela quem nomeia o dono. */
function constanteDaSpec(nome: string): string {
const m = spec.match(new RegExp(`const ${nome} = "([^"]+)";`));
expect(m, `a spec deixou de declarar ${nome} — este guard virou peso morto`).toBeTruthy();
return m![1]!;
}
/** A linha do gerador, lida DENTRO do heredoc que escreve o `.env.e2e`. */
function linhaDoGerador(nome: string): string {
const corpo = gerador.slice(gerador.indexOf("<<EOF") + 5, gerador.indexOf("\nEOF"));
const m = corpo.match(new RegExp(`^${nome}=(.*)$`, "m"));
expect(m, `${nome} não está dentro do heredoc do .env.e2e — o job não a receberia`).toBeTruthy();
return m![1]!;
}
describe("o CI cria o dono que a spec da instalação fresca exige", () => {
it("a spec exige mesmo o dono do bootstrap (guarda de vacuidade)", () => {
// Sem isto, o dia em que a spec parar de exigir o dono os casos abaixo
// continuariam verdes vigiando uma regra que não existe mais.
expect(spec).toMatch(/scripts\/bootstrap-owner\.ts/);
expect(constanteDaSpec("OWNER_EMAIL")).toContain("@");
expect(constanteDaSpec("OWNER_PASSWORD").length).toBeGreaterThanOrEqual(12);
});
it("o workflow publica o .env.e2e no job — é de lá que o dono sai", () => {
expect(workflow).toMatch(/\.env\.e2e >> "\$GITHUB_ENV"/);
});
it("algum passo do workflow cria o dono (bootstrap-owner.ts)", () => {
const dono = indiceDe((l) => l.includes("scripts/bootstrap-owner.ts"));
expect(
dono,
"nenhum passo roda scripts/bootstrap-owner.ts: a parte 4 vai morrer no beforeAll da spec, " +
"antes do primeiro teste, com 'nao achou o dono'",
).toBeGreaterThan(-1);
});
it("o passo do dono vale para a parte 4 — as outras três já nascem semeadas", () => {
// Fora de comentário: a menção em prosa (`SPECS_PARTE_4`, este arquivo) não é passo.
const i = LINHAS.findIndex(
(l) => l.includes("scripts/bootstrap-owner.ts") && !l.trim().startsWith("#"),
);
expect(i, "nenhum passo cria o dono — o caso acima já reprovou por este motivo").toBeGreaterThan(
-1,
);
const guarda = LINHAS.slice(0, i)
.reverse()
.find((l) => /^\s*-?\s*if:/.test(l));
expect(
guarda,
"o passo do dono ficou sem `if:` — rodaria também nas partes 1 a 3, cujo banco tem os " +
"donos semeados e NÃO tem o dono do bootstrap",
).toBeTruthy();
expect(guarda).toMatch(/matrix\.parte == 4/);
});
it("o dono nasce ANTES de a suíte rodar — existir não basta, tem de vir antes", () => {
const dono = indiceDe((l) => l.includes("scripts/bootstrap-owner.ts"));
// `playwright install` também casa com /playwright/; o que roda a suíte é o `test`.
const suite = indiceDe((l) => /playwright test/.test(l) && !/install/.test(l));
expect(suite, "nenhum passo roda a suíte — o padrão envelheceu").toBeGreaterThan(-1);
expect(dono, "nenhum passo cria o dono — não há o que ordenar").toBeGreaterThan(-1);
expect(
dono,
"o dono é criado DEPOIS de a suíte rodar — a spec não espera a corrida",
).toBeLessThan(suite);
});
it("as credenciais do dono têm uma fonte só, e ela concorda com a spec", () => {
for (const nome of ["OWNER_EMAIL", "OWNER_PASSWORD"]) {
expect(
workflow,
`${nome} redigitado no workflow — a fonte única é o .env.e2e, que o job publica`,
).not.toMatch(new RegExp(`^\\s*-?\\s*${nome}[:=]`, "m"));
expect(
linhaDoGerador(nome),
`${nome} diverge entre o gerador do .env.e2e e a spec — duas fontes que concordam por ` +
"coincidência duram até a primeira divergência",
).toBe(constanteDaSpec(nome));
}
});
it("o .env.e2e que o gerador escreve sobrevive ao `source` do build", () => {
// O build lê o arquivo com `set -a; . ./.env.e2e` (scripts/e2e-build.sh), e o
// passo "Publicar o .env.e2e no ambiente do job" copia as mesmas linhas —
// LITERAIS — para o `$GITHUB_ENV`. Só um valor sem espaço satisfaz os dois:
// com espaço, o `source` executa o resto como comando; com aspas, a aspa vai
// embora dentro do valor. Foi assim que `OWNER_ORG_NAME=Loja QA VPS` deixou o
// e2e vermelho nas quatro partes — este caso CARREGA o arquivo para que a
// próxima linha dessas não precise ser descoberta em log de CI.
const corpo = gerador.slice(gerador.indexOf("<<EOF") + 5, gerador.indexOf("\nEOF"));
const valores = corpo
.split("\n")
.filter((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))
.map((l) => l.slice(l.indexOf("=") + 1));
expect(valores.length, "o heredoc do .env.e2e sumiu — o padrão envelheceu").toBeGreaterThan(5);
for (const valor of valores) {
expect(
valor,
"valor com espaço no .env.e2e: o `source` do build quebra e o `$GITHUB_ENV` " +
"leva a aspa junto — use um token só",
).not.toMatch(/\s/);
}
const pasta = fs.mkdtempSync(path.join(os.tmpdir(), "env-e2e-"));
const arquivo = path.join(pasta, ".env.e2e");
fs.writeFileSync(arquivo, corpo.replace(/\\`/g, "`"), "utf8");
const r = spawnSync(
"bash",
[
"-c",
'set -a; . "$1"; set +a; printf "%s\\n" "$OWNER_EMAIL" "$OWNER_PASSWORD" "$OWNER_ORG_NAME"',
"bash",
arquivo,
],
{ encoding: "utf8" },
);
expect(r.stderr, "o .env.e2e não carrega com `source` — é assim que o build lê").not.toMatch(
/command not found/,
);
expect(r.status, `source falhou: ${r.stderr}`).toBe(0);
expect(r.stdout.trim().split("\n")).toEqual([
linhaDoGerador("OWNER_EMAIL"),
linhaDoGerador("OWNER_PASSWORD"),
linhaDoGerador("OWNER_ORG_NAME"),
]);
});
it("os comentários do heredoc não expandem nada — ele é `<<EOF` sem aspas", () => {
// Sem aspas no delimitador, o shell expande `$` e crase ANTES de escrever o
// arquivo: um `$GITHUB_ENV` cru vira o caminho do job — e, com `set -u` e a
// variável ausente (máquina de quem desenvolve, fora do Actions), o gerador
// morre em `unbound variable` e o `pnpm e2e:env` nunca escreve o arquivo.
// Medido nos dois lados. Comentário aqui tem de ser inerte.
const corpo = gerador.slice(gerador.indexOf("<<EOF") + 5, gerador.indexOf("\nEOF"));
const comentarios = corpo.split("\n").filter((l) => l.trimStart().startsWith("#"));
expect(comentarios.length, "nenhum comentário no heredoc — o padrão envelheceu").toBeGreaterThan(
3,
);
for (const linha of comentarios) {
expect(
linha,
"`$` ou crase crus em comentário dentro do heredoc: o shell expande isto ao escrever " +
"o .env.e2e — escape (`\\$`, `\\``)",
).not.toMatch(/[^\\][$`]/);
}
});
});
@@ -0,0 +1,261 @@
/**
* A PARTE 4 DO E2E TEM DE FALAR COM OS SERVIÇOS QUE ELA MESMA SOBE.
*
* ## Os defeitos
*
* A parte 4 (`vps-fresh-onboarding`) sobe WAHA, redis e serverless-redis-http
* (hoje por `docker run` no passo "Subir WAHA e o par Redis da VPS fresca";
* no run citado, como `services:` do job) e publica as URLs deles no passo
* "Ligar a VPS fresca". No run 35124188017 três coisas estavam erradas ao mesmo tempo, e
* NENHUMA dizia o próprio nome:
*
* 1. **O app falava com outras portas.** O passo anexava
* `WAHA_API_BASE_URL=…:3000` ao `$GITHUB_ENV`, mas o servidor recebe o
* ambiente pelo `webServer.env` do `playwright.config.ts`, que é o ARQUIVO
* `.env.e2e` — e ali, em colisão, vence o arquivo (`…:3999`, porta vazia).
* O contêiner do WAHA registrou duas requisições no job inteiro, nenhuma do
* app; o QR nunca apareceu.
* 2. **A espera pelo Redis falhava aberto.** O laço terminava sem `exit 1`, e
* a sonda nunca teria passado: sem `Content-Type: application/json` o
* serverless-redis-http responde 400 (medido contra o digest do compose).
* 3. **O WAHA rodava WEBJS**, e `compatibleSession()` recusa qualquer engine
* que não seja NOWEB.
*
* O item 1 é uma classe, não uma instância: toda chave nova que o gerador do
* `.env.e2e` escrever e o passo da parte 4 sobrepuser repete o defeito. Por
* isso o caso dele EXECUTA o trecho do passo contra as linhas reais do gerador
* e lê o resultado como o `playwright.config.ts` lê — em vez de procurar a
* forma de um comando.
*/
import { spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as http from "node:http";
import type { AddressInfo } from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
const RAIZ = path.resolve(__dirname, "../..");
const ler = (rel: string) => fs.readFileSync(path.join(RAIZ, rel), "utf8");
const workflow = ler(".github/workflows/e2e.yml");
const compose = ler("docker-compose.prod.yml");
const gerador = ler("scripts/gerar-env-e2e.sh");
/** O corpo do `run: |` de um passo, sem a indentação do YAML. */
function runDoPasso(nome: string): string {
const linhas = workflow.split("\n");
const i = linhas.findIndex((l) => l.includes(`name: ${nome}`));
expect(i, `o passo "${nome}" sumiu do e2e.yml — este guard virou peso morto`).toBeGreaterThan(-1);
const r = linhas.findIndex((l, j) => j > i && /^\s*run: \|\s*$/.test(l));
expect(r, `o passo "${nome}" não tem mais um bloco run: |`).toBeGreaterThan(i);
const corpo: string[] = [];
let recuo = -1;
for (const l of linhas.slice(r + 1)) {
if (l.trim() === "") {
corpo.push("");
continue;
}
const n = l.length - l.trimStart().length;
if (recuo === -1) recuo = n;
if (n < recuo) break;
corpo.push(l.slice(recuo));
}
return corpo.join("\n");
}
function secao(script: string, inicio: string, fim: string): string {
const a = script.indexOf(inicio);
const b = script.indexOf(fim);
expect(a, `o trecho "${inicio}" sumiu do passo`).toBeGreaterThan(-1);
expect(b, `o trecho "${fim}" sumiu do passo`).toBeGreaterThan(a);
return `set -euo pipefail\n${script.slice(a, b)}`;
}
/** Mesma leitura do `envDoE2E()` do playwright.config.ts: a última atribuição vence. */
function lerComoOPlaywright(bruto: string): Record<string, string> {
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);
}
return env;
}
const PASSO = runDoPasso("Ligar a VPS fresca (WAHA, Redis e dublê de SaaS)");
const NOME_DO_PASSO_DOS_SERVICOS = "Subir WAHA e o par Redis da VPS fresca";
/** O passo INTEIRO (env: e run:), da linha do nome até o passo seguinte. */
function passoInteiro(nome: string): string {
const linhas = workflow.split("\n");
const i = linhas.findIndex((l) => l.includes(`name: ${nome}`));
expect(i, `o passo "${nome}" sumiu do e2e.yml — este guard virou peso morto`).toBeGreaterThan(-1);
const fim = linhas.findIndex((l, j) => j > i && /^ {6}- /.test(l));
return linhas.slice(i, fim === -1 ? undefined : fim).join("\n");
}
/** Um diretório com `docker` e `sleep` de mentira: a sonda roda sem esperar nem sujar nada. */
function pathComShims(): { pasta: string; PATH: string } {
const pasta = fs.mkdtempSync(path.join(os.tmpdir(), "parte4-"));
const bin = path.join(pasta, "bin");
fs.mkdirSync(bin);
for (const nome of ["docker", "sleep"]) {
fs.writeFileSync(path.join(bin, nome), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
}
return { pasta, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` };
}
describe("a parte 4 do e2e fala com os serviços que ela sobe", () => {
it("o SERVIDOR recebe as URLs do runner — o .env.e2e é reescrito, não só o $GITHUB_ENV", () => {
const trecho = secao(PASSO, "# 2. O que a spec fresca", "# 3. A cadeia de Redis");
const { pasta, PATH } = pathComShims();
// As linhas que o GERADOR escreve para as chaves que o passo sobrepõe — as
// mesmas que, em colisão, venciam no servidor.
const corpoDoGerador = gerador.slice(gerador.indexOf("<<EOF") + 5, gerador.indexOf("\nEOF"));
const doGerador = lerComoOPlaywright(corpoDoGerador);
const chavesDoPasso = [...trecho.matchAll(/echo "([A-Z0-9_]+)=/g)].map((m) => m[1]!);
const colisoes = chavesDoPasso.filter((k) => k in doGerador);
expect(
colisoes,
"nenhuma chave do passo existe no gerador — sem colisão, este caso não mede nada",
).toContain("WAHA_API_BASE_URL");
const envE2e = path.join(pasta, ".env.e2e");
fs.writeFileSync(
envE2e,
["# gerado", "SENTINELA=fica", ...colisoes.map((k) => `${k}=${doGerador[k]}`), ""].join("\n"),
);
const githubEnv = path.join(pasta, "github_env");
fs.writeFileSync(githubEnv, "");
const r = spawnSync("bash", ["-c", trecho.replaceAll("/tmp/", `${pasta}/`)], {
cwd: pasta,
encoding: "utf8",
env: { ...process.env, PATH, GITHUB_ENV: githubEnv },
});
expect(r.status, `o trecho do passo falhou: ${r.stderr}`).toBe(0);
const doPasso = lerComoOPlaywright(fs.readFileSync(githubEnv, "utf8"));
const servidor = lerComoOPlaywright(fs.readFileSync(envE2e, "utf8"));
for (const chave of chavesDoPasso) {
expect(
servidor[chave],
`${chave}: o processo de teste recebe "${doPasso[chave]}" e o servidor (webServer.env ← .env.e2e) ` +
`recebe "${servidor[chave]}" — em colisão vence o arquivo`,
).toBe(doPasso[chave]);
}
expect(servidor.SENTINELA, "a reescrita apagou linhas que não eram da parte 4").toBe("fica");
});
it("a espera pelo Redis falha FECHADO quando o serviço não responde", () => {
const trecho = secao(PASSO, "# 3. A cadeia de Redis", "# 4. Alguns scripts");
expect(trecho, "a sonda deixou de mirar a porta publicada do redis-http").toContain(
"127.0.0.1:8079",
);
const { PATH } = pathComShims();
// A porta 1 não tem ninguém escutando: a sonda recebe "connection refused"
// na hora, e o `sleep` de mentira faz as voltas custarem nada.
const r = spawnSync(
"bash",
["-c", `${trecho.replaceAll("127.0.0.1:8079", "127.0.0.1:1")}\necho SEGUIU`],
{ encoding: "utf8", env: { ...process.env, PATH } },
);
expect(r.stdout, "a espera terminou e o passo SEGUIU sem Redis — falha aberta").not.toContain(
"SEGUIU",
);
expect(r.status).not.toBe(0);
});
it("a sonda do Redis fala o protocolo do serverless-redis-http (JSON, com Content-Type)", async () => {
const trecho = secao(PASSO, "# 3. A cadeia de Redis", "# 4. Alguns scripts");
const { PATH } = pathComShims();
// O contrato medido contra o digest do compose: sem `application/json`, 400.
// Conta os PONGs: "o passo seguiu" sozinho também é o que uma espera que
// falha aberto faz, e aí este caso passaria sem a sonda nunca ter acertado.
let pongs = 0;
const srh = http.createServer((req, res) => {
const json = (req.headers["content-type"] ?? "").startsWith("application/json");
if (json) pongs += 1;
res.writeHead(json ? 200 : 400, { "content-type": "application/json" });
res.end(
json
? JSON.stringify({ result: "PONG" })
: JSON.stringify({ error: "Invalid content type. Expected application/json." }),
);
});
await new Promise<void>((ok) => srh.listen(0, "127.0.0.1", ok));
const porta = (srh.address() as AddressInfo).port;
const r = await new Promise<{ status: number | null; stdout: string }>((ok) => {
const p = spawn(
"bash",
["-c", `${trecho.replaceAll("127.0.0.1:8079", `127.0.0.1:${porta}`)}\necho SEGUIU`],
{ env: { ...process.env, PATH } },
);
let stdout = "";
p.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
p.on("close", (status) => ok({ status, stdout }));
});
await new Promise<void>((ok) => srh.close(() => ok()));
expect(pongs, "a sonda nunca mandou uma requisição que o serverless-redis-http aceita").toBeGreaterThan(0);
expect(r.stdout, `o passo não seguiu com o Redis de pé: ${r.stdout}`).toContain("SEGUIU");
expect(r.status).toBe(0);
});
it("o WAHA do CI roda NOWEB — o único engine que o produto aceita", () => {
// Guarda de vacuidade: se o produto deixar de exigir NOWEB, este caso passa
// a vigiar uma regra que não existe mais.
expect(ler("lib/waha/client.ts")).toMatch(/actualEngine !== "NOWEB"/);
const passo = passoInteiro(NOME_DO_PASSO_DOS_SERVICOS);
expect(passo, "sem a linha o contêiner cai no default WEBJS e o createSession lança").toMatch(
/^\s*WHATSAPP_DEFAULT_ENGINE:\s*["']?NOWEB["']?\s*$/m,
);
// A variável só chega ao contêiner se o `docker run` a repassar.
expect(passo, "o docker run do WAHA não repassa o engine ao contêiner").toMatch(
/docker run[^\n]*(\\\n[^\n]*)*-e WHATSAPP_DEFAULT_ENGINE/,
);
});
it("o serverless-redis-http do CI é a MESMA imagem do docker-compose.prod.yml", () => {
const doCompose = compose.match(/image:\s*(hiett\/serverless-redis-http\S*)/)?.[1];
const doCi = passoInteiro(NOME_DO_PASSO_DOS_SERVICOS).match(
/SRH_IMAGE:\s*(hiett\/serverless-redis-http\S*)/,
)?.[1];
expect(doCompose, "o compose deixou de declarar o serverless-redis-http").toBeTruthy();
expect(doCi, "tag móvel ou imagem diferente da que o self-hoster recebe").toBe(doCompose);
});
it("os contêineres sobem SÓ na parte 4 — nada de `services:` no job da matriz", () => {
// `services:` é chave do JOB: sobe nas quatro pernas. Custou ~51 s de
// "Initialize containers" a cada parte que não usa nenhum deles (run
// 35124188017), com as partes a ~2 min do teto de 30.
const job = workflow.slice(workflow.indexOf("\n e2e-parte:"), workflow.indexOf("\n steps:", workflow.indexOf("\n e2e-parte:")));
expect(job, "o recorte do job e2e-parte saiu vazio — este caso virou peso morto").toContain("timeout-minutes");
expect(job, "`services:` voltou ao job: os contêineres da parte 4 sobem nas quatro pernas").not.toMatch(/^ {4}services:/m);
const passo = passoInteiro(NOME_DO_PASSO_DOS_SERVICOS);
const linhas = workflow.split("\n");
const i = linhas.findIndex((l) => l.includes(`name: ${NOME_DO_PASSO_DOS_SERVICOS}`));
expect(linhas[i - 1], "o passo dos contêineres perdeu o `if: matrix.parte == 4`").toMatch(
/^\s*- if: matrix\.parte == 4\s*$/,
);
for (const imagem of ["WAHA_IMAGE", "REDIS_IMAGE", "SRH_IMAGE"]) {
expect(passo, `o passo não sobe ${imagem}`).toContain(`"$${imagem}"`);
}
// O srh fala com `redis://redis:6379`: sem a rede e o alias, o nome não resolve.
expect(passo).toMatch(/--network-alias redis\b/);
expect(passo).toContain("SRH_CONNECTION_STRING=redis://redis:6379");
// E antes de quem espera por eles: a sonda do passo "Ligar a VPS fresca"
// não tem o que achar se os contêineres subirem depois.
const ligar = linhas.findIndex((l) => l.includes("name: Ligar a VPS fresca"));
expect(i, "os contêineres sobem DEPOIS do passo que espera por eles").toBeLessThan(ligar);
});
});
@@ -153,4 +153,27 @@ describe("o workflow do e2e honra o contrato de ambiente que a suíte exige", ()
).toBe(false);
expect(packageJson.scripts["e2e:build"]).toBeTruthy();
});
it("o WAHA do CI sobe sem exigir chave — ele nasce antes de o .env.e2e existir", () => {
// Terceira ponta do MESMO contrato, e a que só o CI revelou: os serviços do
// job sobem ANTES do primeiro passo, e a chave mora no `.env.e2e`, que um
// passo publica — não há caminho dela até o contêiner, e a guarda acima
// proíbe redigitá-la no workflow. O que o run 35100039158 mostrou é que "sem
// `WAHA_API_KEY`" NÃO quer dizer "sem autenticação": o WAHA sorteia uma chave
// no boot (`core/auth/config.js` do pin: `rand()`), e aí todo `X-Api-Key` que
// o CRM manda — o do `.env.e2e` — toma 401. O serviço fica de pé, o passo que
// espera por ele passa, e o vermelho aparece longe daqui, em spec. `True` em
// `WAHA_NO_API_KEY` é o que faz a chave resolver vazia e o
// `ApiKeyAuthFactory` cair em `NoAuth`.
// Hoje o WAHA sobe por `docker run` num passo que roda antes do que publica
// o `.env.e2e` (para o pull correr junto com o resto do job) — o mesmo
// problema de ordem que o `services:` tinha.
const inicio = workflow.indexOf("name: Subir WAHA e o par Redis da VPS fresca");
expect(inicio, "o passo que sobe o WAHA sumiu — este caso virou peso morto").toBeGreaterThan(-1);
const servico = workflow.slice(inicio, workflow.indexOf("\n - ", inicio));
expect(
servico,
"sem WAHA_NO_API_KEY o contêiner do CI sorteia uma chave no boot e o CRM toma 401",
).toMatch(/WAHA_NO_API_KEY\s*:\s*["']?(true|True|1)/);
});
});