mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 ──"
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user