test(crm-vivo): o aparato das waves 0-3 entra no repo, e o check para de passar vazio

DOIS PROBLEMAS, e o segundo é dentro do próprio guarda.

1. O APARATO NÃO ESTAVA VERSIONADO. Doze arquivos — as capturas das waves 0, 1, 2
   e 3, as da LGPD, o compositor antes/depois, a prova do canal morto e dois seeds.
   Sem eles NENHUMA prova das waves anteriores é reproduzível por quem clona: é o
   achado do @MaestroConexoes sobre o qa-helpers, doze vezes. Entram junto as quatro
   narrativas por wave (evidence/*.md).

2. O CHECK DA EVIDÊNCIA CITADA PASSAVA VAZIO NOS DOCUMENTOS QUE MAIS IMPORTAVAM.
   Ele casava só `evidence/*.png` — mas os documentos que vivem DENTRO de evidence/
   citam por nome puro (`wave-1-card.png`). Resultado: ao incluí-los no check, os
   quatro passaram na hora, e o verde era VÁCUO — dentro do guarda escrito contra
   exatamente isso.
   Corrigido: as referências são normalizadas contra o diretório do documento antes
   de comparar com `git ls-files`.
   Com a correção ele REPROVOU em quatro documentos, inclusive apontando uma imagem
   citada no meu próprio HANDOFF e nunca versionada.

Resultado: 18 imagens citadas versionadas (~1,8 MB), e nenhuma citação aponta para
arquivo inexistente no disco. O evidence/ inteiro (6,7 MB) continua fora — `git` não
esquece, e num projeto aberto todo clone pagaria o peso de imagem que ninguém cita.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gpdoAwEGCcM9DwzC6qtkL
This commit is contained in:
Rafael Melgaço
2026-07-24 23:34:48 -03:00
co-authored by Claude Opus 5
parent 0ec130bc94
commit 8e2056567e
29 changed files with 3710 additions and 3 deletions
+87
View File
@@ -0,0 +1,87 @@
# LGPD — prova do ciclo de anonimização · 2026-07-24
Evidência produzida por **@QAVivo**. Duas frentes: o **ensaio** (concluído, 9/9) e a
**prova real** (bloqueada por um bug encontrado no caminho).
## Resultado do ensaio — 9/9 PASS
`tests/capture-lgpd-ensaio-tenant-b.ts`, no tenant descartável, com `store_redact`.
| # | Verificação | Resultado |
|---|---|---|
| 1 | UI: a tela de aprovação **abre** | PASS |
| 2 | UI: aprovação transiciona o pedido | PASS — `received` → `processing` |
| 3 | Worker de LGPD executou | PASS — 1 evento, `tenant_completed` |
| | **o que TEM de sumir** | |
| 4 | Contato anonimizado | PASS — `Cliente Anonimizado #877bfe4f` |
| 5 | `activity.reason` → NULL | PASS — era texto com telefone do titular |
| 6 | `activity.payload` / `metadata` zerados | PASS — `{}` e `{}` |
| | **o que NÃO pode sumir** | |
| 7 | `activity.evidence` sobreviveu | PASS — idêntico antes/depois |
| 8 | `activity.actor_kind` sobreviveu | PASS — `ai` → `ai` |
| 9 | `created_at` preservado | PASS |
**A doutrina se confirma na prática:** o cascade apaga o que é PII e preserva o que é
auditoria. Não apaga demais nem de menos — e só o teste dos **dois lados** mostra isso.
## Dois bugs encontrados, ambos no caminho da tela
### 1. A tela de aprovação cai — vocabulário divergente
`ApproveButton.tsx` faz `VARIANT_LABELS[requestType].button`.
| | valores |
|---|---|
| O que a **UI** conhece | `customer_data_request` · `customer_redact` · `store_redact` |
| O que o **banco** aceita (`lgpd_requests_request_type_check`) | `data_request` · `redact` · `store_redact` |
| Interseção | **só `store_redact`** |
Um pedido `redact` (anonimizar **um cliente**) faz `VARIANT_LABELS['redact']` ser
`undefined`, `.button` lança, e a página inteira cai no error boundary.
**Dos três tipos, dois quebram** — e são justamente os dois que tratam de **um titular**.
Não dá nem para criar um pedido no vocabulário da UI: o banco **rejeita** `customer_redact`.
### 2. O filtro esconde o pedido — mesma raiz, efeito pior
Filtrar por "Anonimização cliente" **não devolve nada** (controle: filtrar por outro tipo
também esconde, provando que o filtro aplica de verdade). A lista ainda mostra o tipo cru
(`redact`) em vez do rótulo humano.
> **A tela que quebra grita; o filtro que não acha é silencioso.** O admin filtra, vê lista
> vazia, conclui "não há pedido pendente" e vai embora — com o prazo legal correndo.
Critério de re-teste: não basta a tela abrir — **o filtro tem que achar**.
## Débito registrado (não construído)
**Não existe runner por handler.** `processLgpdRedact` é função pura sem `main`, e o único
script de worker é o genérico, que carrega **todos** os handlers — inclusive os de envio.
Consequência operacional real, além desta entrega: em produção **não dá para processar uma
classe de evento isoladamente**, nem **reprocessar um evento específico** depois de um
incidente. Num sistema que promete SLA de LGPD, isso é limitação de operação, não detalhe.
O runner deste teste (escopo cirúrgico: só `lgpd.redact_received`, só a org alvo, com
guarda que recusa qualquer coisa fora disso) resolve o ensaio — **não** substitui o que
falta em produção.
## O que o ambiente impediu
- **Sem worker rodando**: `healthz` 8787 mudo e **139 eventos de envio pendentes**
(`message.sent` / `message.outbound`), alguns de **29 de abril**. Subir o worker genérico
dispararia mensagem de três meses atrás para contato real.
- **Mensagens fora do ensaio**: o tenant descartável não tem `channel_session`, e criar uma
seria inventar um número de WhatsApp inexistente. O ensaio cobre contato + atividades;
a cobertura de mensagens entra na prova real.
## Nota de método — o alcance é que define o risco
`store_redact` anonimiza o **tenant inteiro**: na org de teste seriam **55 contatos, 132
mensagens e 51 leads**, irreversíveis. Por isso o ensaio foi no tenant descartável.
Mas a **prova real não precisa disso**: `redact` é *contact-scoped* — toca só o titular
alvo. Quando o vocabulário for alinhado, roda direto na org de teste, sem risco.
**A regra não é "prova de LGPD exige tenant separado". É: o que define o risco é o alcance
da operação, não o assunto dela.**
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+100
View File
@@ -0,0 +1,100 @@
# Wave 0 — CRMV0 · linha de base visual · 2026-07-24
Evidência produzida por **@QAVivo**. O handoff oficial é escrito pelo regente (§10);
este arquivo é o índice da evidência, para ser citado lá.
## Como a evidência foi produzida
`tests/capture-wave-0.ts` — navegador próprio (`chromium.launch`), viewport
**1440×900**, contra o dev server da **3020**.
Caminho percorrido, **inteiro por clique**, como um usuário:
1. `/login` → digita e-mail e senha do **manager** (`e2e-manager@deskcomm.test`);
2. cai em `/app/inbox` → clica em **Kanban** no menu lateral;
3. na lista de pipelines, clica na linha **"CRM Vivo — Clínica"**;
4. board carregado → capturas.
O script também cobre o **admin** (`E2E_USER=admin`), que tem MFA TOTP forçado: o
código de 6 dígitos é digitado dígito a dígito no campo de slots — `fill` não dispara
os handlers e o botão fica desabilitado; o formulário auto-submete no sexto dígito.
Nenhuma navegação por URL direta; nenhuma asserção de API ou de banco foi tratada
como prova.
## Capturas
| Arquivo | O que mostra |
|---|---|
| `wave-0-board-antes.png` | Board inteiro (fullPage, 1440×900), 10 leads em 4 colunas — **a linha de base da Wave 2** |
| `wave-0-card-antes.png` | Close do card "Clínica Vitalis — implantes" |
| `wave-0-card-titulo-longo-antes.png` | Close do card de título de 123 caracteres |
| `wave-0-navegacao.png` | Lista de pipelines — o board alcançável por clique |
| `wave-0-board-antes-full.png` | Captura anterior (viewport 1600×1000), mantida como referência |
## Medidas (por ferramenta, não a olho)
Altura de cada card no board, em px:
| Altura | Card |
|---:|---|
| 116 | Marina Costa — clareamento |
| 119 | Rogério Paiva — avaliação inicial |
| 143 | Clínica Vitalis — implantes |
| 143 | Família Andrade — 4 tratamentos |
| 143 | Bruno Tavares — protocolo superior |
| 143 | Caio Ribeiro — dor de dente |
| 143 | Patrícia Nunes — prótese fixa |
| 145 | Helena Marques — ortodontia adulto |
| 154 | Grupo Odonto Sul — contrato corporativo |
| 154 | Clínica Vitalis — pacote completo… (título de 123 chars) |
**min 116 · max 154 · variação 38px em 10 cards.**
O mais alto (154px) é o de **título de 2 linhas**; o mais baixo (116px) é o **sem tag**.
Hoje a altura é função do conteúdo — exatamente o que a Lei B da §5 proíbe.
Este é o número que a Wave 2 tem de zerar: a Lei B da §5 exige **altura constante**.
Hoje o card cresce com o dado — título de 2 linhas soma altura, valor ausente subtrai.
## Matriz do seed, conferida no banco
`scripts/seed-crm-vivo.ts` rodado **3× seguidas**: mesmos ids, mesmo board — idempotente.
| Requisito | Estado | Evidência |
|---|---|---|
| mínimo 8 leads | PASS | 10 leads |
| espalhados por estágios | PASS | 4 dos 6 estágios ocupados |
| lead com dono humano | PASS | 8 com dono |
| lead SEM dono | PASS | 2 sem dono |
| lead com valor nulo | PASS | "Rogério Paiva — avaliação inicial" |
| título ≥ 120 caracteres | PASS | 123 chars |
| lead com 8 tags | PASS | "Família Andrade — 4 tratamentos" |
| esfriando (estoura `expected_duration_hours`) | PASS | Bruno Tavares 150h > 72h · Helena Marques 480h > 96h · Patrícia Nunes 60h > 48h |
| estágios com janela declarada | PASS | 24h / 48h / 72h / 96h |
| não colide com `seed-e2e-kanban.ts` | PASS | pipeline e títulos próprios |
## Observações da linha de base (não são bugs — são o "antes")
1. **Altura não é constante** (38px de variação) — alvo da Wave 2.
2. **Tags estão no card** ("primeira-consulta", "implante", `+5`) — a §5 manda ir para
hover, deixando só **uma** tag canônica como ponto de 6px.
3. **Valor nulo não quebra** — o card de "Rogério Paiva" simplesmente omite a linha de
valor; não aparece `R$ 0` nem `NaN`. Mas é *essa omissão* que encurta o card (119px).
4. **Título de 123 caracteres não quebra o layout** — trunca em 2 linhas com reticências.
5. **Nada no card diz quem está esfriando.** Bruno Tavares (150h) e Helena Marques
(480h, e sem dono) parecem iguais aos demais. No teste do metro, hoje dá para ver
*quem é o dono*, mas **não** *quais cards pedem atenção* — é a lacuna que o slot
(Wave 2) e o CORE 5 (Wave 7) existem para fechar.
6. **Console: HTTP 429** (`Too Many Requests`) em 1–2 recursos durante a carga do board.
Não impediu a renderização. Registrado para observação — se reaparecer sob os e2e,
vira achado de rate limit.
## O que ficou para trás
- **Entrega 1 não foi escrita por mim.** `scripts/seed-crm-vivo.ts` já existia quando
fui gravar (mtime 18:06). Não sobrescrevi — a regra 1 da §10 manda parar e falar.
Meu papel virou **verificar** o seed, não reescrevê-lo.
- **Densidade "Compacta" não foi exercitada** — é cenário da Wave 2, fora deste despacho.
- **`axe-core` não rodou nesta wave** — a Wave 0 não altera UI; entra na primeira wave
que tocar o card.
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+144
View File
@@ -0,0 +1,144 @@
# Wave 1 — CORE 1 · a IA é dona do negócio · 2026-07-24
Evidência produzida por **@QAVivo** via `tests/capture-wave-1.ts` (navegador próprio,
viewport 1440×900, dev server 3020). Navegação **100% por clique**; toda atribuição foi
feita pelo menu do card, como um usuário faria.
**Rodada final — resultado: 11 PASS / 1 FALHA de 12 itens. A falha, nomeada:**
- **`5` — `axe-core: nested-interactive`.** Esperado 0 violações *serious*; obtido 11
nós. **Herdada da `main`**, provada por git; aceita como dívida a pagar na Wave 2.
O item **`2.2` (separador segregando agentes dos humanos) fechou VERDE** — 0 separadores
entre o último humano e o primeiro agente, depois do refactor do seletor para lista única.
### Contra qual código estas capturas rodaram
| | |
|---|---|
| `HEAD` na captura | `c38cbe7` — *refactor(crm-vivo): seletor de responsável em lista única, com o avatar do card* |
| Cadeia | `c38cbe7` → `c7ce4ec` (helper `resolveOwnerPatch`) → `b247537` (CORE 1) → `3b4c193` (base) |
| Helper incluído? | `git merge-base --is-ancestor c7ce4ec HEAD` → **sim** |
| Working tree | `FilterBar.tsx`, `owner-patch.ts`, `_handler.ts` **limpos** — a captura equivale ao commit, não a um estado intermediário de disco |
O refactor **não** piorou o axe: `nested-interactive` seguia com **11 nós antes e 11 depois**.
Se o `OwnerBadge` dentro do `DropdownMenuItem` tivesse criado interativo aninhado, a
contagem teria subido. Não subiu.
### Declaração: uma asserção foi alterada nesta mesma rodada
Mudar teste e vê-lo ficar verde na mesma execução não pode passar calado.
- **O que mudou:** a detecção do item 2.2 identificava humano por texto **exato**
(`^E2E Admin$`). Com o seletor em lista única, cada item passa a renderizar o
`OwnerBadge` junto do nome — o texto virou `EAE2E Admin`. Com a regra antiga, nenhum
humano seria encontrado e o 2.2 daria *"não deu para medir"*: **vermelho por engano**,
culpando o autor por um acerto.
- **O que NÃO mudou:** o critério. Continua *"conte os `[role=separator]` **entre** o
último humano e o primeiro agente, em ordem de DOM, e exija **zero**"*. O conserto foi
no localizador que alimenta o critério, não no critério.
Os **4 cenários da §7 passaram**, mais os dois casos de tooltip e a paridade de avatar.
> Placar sempre colado ao item que faltou. Contagem sem o nome da falha é onde falha
> some — regra do regente, adotada.
### O item 2.2 nasceu de uma fraqueza deste próprio arquivo de teste
A asserção original do cenário 2 era: *"existe item humano **e** existe item agente no
mesmo menu"*. Um `DropdownMenuSeparator` segregando os agentes **passaria** por ela — ou
seja, o teste carimbaria exatamente o que o regente reprovou duas vezes na revisão manual.
O item 2.2 percorre o menu em **ordem de DOM**, localiza o último humano e o primeiro
agente, e conta os `[role="separator"]` **entre** eles. Zero = lista mesma de verdade.
Reprovação manual virou **gate automático**: quando o separador sair, o item fica verde
sozinho; se alguém reintroduzir daqui a três meses, ele reprova sem depender de ninguém
lembrar.
## Cenários da §7
| # | Cenário | Resultado | Evidência |
|---|---|---|---|
| 1 | Dono-agente do seed **com** versão publicada | **PASS** — `vazadoComAnel=true`, tooltip `Lia — AgendaPlus · v24`, sem emoji, sem badge "AI" | `wave-1-cenario-1-agente-com-versao.png` |
| 1.2 | Dono-agente **sem** versão publicada — borda que o regente pediu | **PASS** — tooltip `Bot Padrão E2E`, **sem ` · v` pendurado**, sem `undefined`/`null`/`NaN` | `wave-1-cenario-1-agente-sem-versao.png` |
| 1.3 | Agente é **par** do humano (§5): mesmo tamanho e peso | **PASS** — agente `24×24 peso 600` **==** humano `24×24 peso 600` | medido via `getBoundingClientRect` + `getComputedStyle` |
| 1.1 | Atribuir a agente **pela UI** | **PASS** — card vira vazado com anel, tooltip correto | `wave-1-cenario-1-card-agente.png` |
| 2 | Filtro "Responsável" lista agentes **junto** dos humanos | **PASS** — menu: `Todos · Sem responsável · Eu · E2E Admin · E2E Agent · Lia — AgendaPlus v24` | `wave-1-cenario-2-filtro-aberto.png` |
| 2.1 | Filtrar por `Lia — AgendaPlus` devolve **só** os leads dela | **PASS** — Rogério (Lia) visível; **Caio (outro agente) sumiu**; lead humano sumiu | `wave-1-cenario-2-filtrado-por-agente.png` |
| 3 | Transferir **agente→humano**, persiste após reload | **PASS** — após `reload()`: `preenchido=true`, `aria="Responsável: E2E Manager"` | `wave-1-cenario-3-dono-humano.png` |
| 3.1 | Transferir **humano→agente**, persiste após reload | **PASS** — após `reload()`: `vazadoComAnel=true`, tooltip `Lia — AgendaPlus · v24` | `wave-1-cenario-3-dono-agente.png` |
| 4 | `viewer` **não** consegue reatribuir | **PASS** — o botão de ações **nem existe** para o viewer | `wave-1-cenario-4-viewer.png` |
| 6 | Fixture devolvido a "Sem responsável" **pela própria UI** | **PASS** | — |
> O cenário 2.1 discrimina **entre agentes**, não só agente-vs-humano: filtrar pela Lia
> esconde o lead do `Bot Padrão E2E`. Filtro que só separasse "IA" de "humano" passaria
> num teste mais frouxo e estaria errado.
## Dois defeitos MEUS de instrumentação, corrigidos no caminho
Nenhum era do produto — mas os dois produziriam resultado enganoso:
1. **`getByRole("heading")` parou de resolver.** O card é `role="button"` (drag handle do
dnd) e, pela regra ARIA de *children presentational*, o `<h3>` de dentro **perde** o
papel de heading assim que o dnd hidrata. O locator funcionava por **corrida**:
resolvia antes da hidratação. Trocado por ancoragem no container `[data-rfd-draggable-id]`.
2. **`networkidle` não bastava.** O board monta os cards depois; o script chegou a ver
**0 cards** e ia acusar board vazio. Trocado por espera determinística pelo primeiro
card — nunca por `sleep` fixo, que só transforma o erro em intermitência.
## axe-core — a única falha, e por que ela não é da Wave 1
`serious: nested-interactive` — 11 nós, um por card.
O nó acusado é o **próprio container arrastável**: `div[role="button"]` do
`@hello-pangea/dnd` contendo o botão `Ações do lead`. Controle interativo dentro de
controle interativo.
**Prova de que é herdado, não introduzido:**
1. `git cat-file -e origin/main:components/kanban/KanbanCardActions.tsx` → o arquivo **já
existia** na base;
2. `git show origin/main:components/kanban/KanbanCard.tsx` → o `dragHandleProps`
(`role="button"`) e o `<KanbanCardActions>` dentro dele **já conviviam** na `main`;
3. `git diff origin/main -- components/kanban/KanbanCard.tsx | grep -E '^[+-].*(button|onClick|role=|tabIndex)'` →
**nenhuma linha**. A Wave 1 não adicionou um único elemento interativo ao card.
Logo o critério da §7 ("axe sem violação **nova**") está **cumprido**.
**Mas é dívida real, não desculpa:** para quem navega por teclado, cada card é um botão
cheio de botões. A Wave 2 reconstrói o card — é ali que isso se paga, de graça.
## Teste do metro — honesto
Olhando `wave-1-board-com-agente.png` a um metro:
- **Dono: agora dá.** Círculo **vazado com anel** (agente) contra **disco preenchido**
(humano), mesmo diâmetro e mesma posição. A distinção é **geométrica**, então
sobrevive ao daltonismo. Nenhum emoji, nenhum badge colorido, nenhum gradiente — a §5
foi respeitada à risca.
- **Ressalva:** o disco humano tem contraste baixo (verde claro sobre branco). A um metro
ambos leem como "círculo claro"; o que separa é a **borda**, não o preenchimento.
Funciona, mas é a leitura mais frágil do card. Vale endurecer na Wave 2.
- **Quem pede atenção: continua não dando.** "Bruno Tavares" (150h parado) e
"Helena Marques" (480h parada e sem dono) seguem idênticos aos saudáveis. Inalterado
em relação à Wave 0 — é o buraco que a Wave 2 e o CORE 5 fecham.
## Correção de um achado meu que estava ERRADO
Na primeira rodada eu registrei que dois leads (`Rogério Paiva`, `Caio Ribeiro`) estavam
com dono-agente por **deriva de fixture**, vinda de teste manual.
**Estava errado, nas duas pontas:**
1. Os donos-agente são **seed intencional** — o regente estendeu `scripts/seed-crm-vivo.ts`
com o bloco da Wave 1, resolvendo o agente **por nome** (não por uuid, para sobreviver
a um clone).
2. Aquele seed **já escreve o trio** `owner_kind` / `owner_user_id` / `owner_agent_id` no
mesmo UPDATE (linhas 332-333) **e já checa o `error`** da resposta (linhas 353-354).
Eu havia lido a versão anterior do arquivo. Conclusão errada, registrada e desfeita.
**O que do achado SOBREVIVE:** `scripts/seed-e2e-kanban.ts` continua sem checar o `error`
do UPDATE (linhas 58-62) — e foi ele, não o outro, que causou o vermelho na linha de base
da Wave 0. Esse conserto continua de pé.
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+71
View File
@@ -0,0 +1,71 @@
# Wave 2 — o slot · card em 3 faixas, altura constante · 2026-07-24
Evidência produzida por **@QAVivo** via `tests/capture-wave-2.ts` (navegador próprio,
viewport 1440×900, dev server 3020). Navegação **100% por clique**.
## Contra qual código
| | |
|---|---|
| `HEAD` na captura | `e9c50d2` — *fix(crm-vivo): o Radar passa a nomear o dono agente e usar a janela do estágio* |
| Cadeia da wave | `e9c50d2` → `769b7f2` (um relógio por card) → `bad6f70` (o slot) → `e2ed…` (Wave 1) |
| Árvore de **produção** | **limpa** — só `tests/`, `evidence/`, `seed-e2e-kanban.ts`, `BRIEFING`, `HANDOFF` fora do commit |
Medição em código **parado**, como combinado: o veredito é sobre o commit, não sobre
trabalho em voo.
## O número da wave
**A variação de 38px virou ZERO.**
| | Wave 0 (antes) | Wave 2 (depois) |
|---|---|---|
| Cards medidos | 10 | **12** |
| Alturas distintas | **5** (116, 119, 143, 145, 154) | **1** |
| Altura | 116→154px | **144px, todos** |
| Variação | **38px** | **0px** |
`Set(alturas).size === 1` — o critério, cumprido sem margem.
## Placar
| # | Verificação | Resultado |
|---|---|---|
| gate altura | `Set(alturas).size === 1` em 12 cards | **PASS** — 144px |
| gate elementos | Lei B: máximo 5 elementos | **PASS** — máx. observado 5 |
| cenário 7 | título de 123 chars + valor nulo + 8 tags não quebram | **PASS** — todos 144×302px |
| axe | zero violação *serious*/*critical* | **PASS** — `nested-interactive` **ZERADO** (era 11 nós) |
| radar 1 | contador crítico cai de 40 para 39 | **PASS** — 39 |
| radar 2 | "Carlos" sai do radar (30h numa janela de 96h) | **PASS** — ausente |
| radar 3 | dono agente nomeado, não "Sem dono" | **PASS** — `Agente: Lia — AgendaPlus` |
| regressão | `kanban-owner-filter` · `rbac-roles` · `risk-radar` | **PASS** — build exit 0, e2e exit 0, **7/7** |
**8 verificações, 8 PASS, 0 FALHA.** Nenhum item ficou sem nome.
## O que a tela mostra agora — teste do metro, honesto
`wave-2-board-depois.png` · `wave-2-antes-depois.png` (lado a lado, cenário 8)
- **Quem é o dono: sim.** O disco humano virou **sólido** — a distinção não depende mais
da borda. Humano = disco cheio; agente = círculo vazado com anel. A ressalva que abri na
Wave 1 está **paga**.
- **Quem pede atenção: AGORA SIM.** É a linha que eu vinha mantendo vermelha desde a
Wave 0. `Sem resposta há 6 dias`, `há 20 dias`, `há 8 dias` em âmbar, com a borda
esquerda do card na mesma cor. A um metro, os cards que gritam são exatamente os que
deveriam gritar.
- **Tags saíram do card.** Sobrou o ponto de 6px da tag canônica antes do título — a
exceção que a §5 permite. Nenhuma pílula de tag, nenhum `+5`.
- **Valor nulo virou `—`.** Ausência explícita, não linha em branco: preserva a altura e
não vira `R$ 0` nem `NaN`.
- **Um relógio por card.** Onde o slot já contou o silêncio (`Sem resposta há 6 dias`), o
rodapé cala o número e diz só `em Proposta enviada`. Onde não há silêncio a contar, o
rodapé conta o tempo (`8h em Proposta enviada`). O mesmo número não aparece duas vezes.
## Fragilidade da própria evidência (achado sobre o meu ferramental)
`evidence/wave-0-board-antes.png` é **artefato histórico**: é a única imagem do board
antes do slot. `tests/capture-wave-0.ts` **sobrescreve esse arquivo** a cada execução —
rodá-lo hoje destruiria o "antes" em silêncio, e o cenário 8 perderia a metade esquerda.
Não é hipótese: o script grava com o mesmo nome, sem checar. Fica registrado como risco;
a correção (proteger o arquivo ou versionar o nome) é decisão do regente.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+243
View File
@@ -0,0 +1,243 @@
/**
* Caso de anonimização LGPD para prova NA TELA (não no RPC).
*
* Monta um titular com rastro em todas as superfícies que o cascade tem de
* varrer — e, de propósito, com dados que **NÃO podem sumir**:
*
* DEVE SUMIR DEVE SOBREVIVER
* contato: nome, e-mail, telefone activity.actor_kind (fato de auditoria)
* mensagens: body, metadata activity.evidence (só ids, não é PII)
* activity.payload / metadata timestamps (histórico preservado)
* activity.reason (texto de LLM)
*
* Um redact que apaga demais é tão errado quanto um que apaga de menos, e só o
* teste dos DOIS lados pega isso.
*
* Idempotente: upsert por e-mail do contato. Grava `lgpd` em .e2e-creds.json.
*
* Run: npx tsx scripts/seed-e2e-lgpd.ts
*/
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import * as path from "node:path";
const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const NOME = "Ana Souza LGPD E2E";
const EMAIL = "ana.souza.lgpd@titular.test";
const TELEFONE = "+5511970001234";
const LEAD_TITLE = "Titular LGPD E2E — pedido de anonimizacao";
/** `reason` é texto livre escrito por LLM: pode carregar PII e por isso tem de sumir. */
const REASON_COM_PII =
"Cliente Ana Souza confirmou o telefone +5511970001234 e pediu retorno no e-mail dela.";
/** `evidence` guarda só ponteiros — decisão do regente: não é PII, tem de sobreviver. */
const EVIDENCE_SEM_PII = { trace_ids: ["trace_abc123"], run_ids: ["run_def456"] };
async function upsert<T extends Record<string, unknown>>(
tabela: string,
busca: Record<string, unknown>,
linha: T,
): Promise<string> {
let q = admin.from(tabela).select("id");
for (const [k, v] of Object.entries(busca)) q = q.eq(k, v as never);
const { data: existente, error: selErr } = await q.maybeSingle();
if (selErr) throw new Error(`buscar ${tabela}: ${selErr.message}`);
if (existente) {
const id = (existente as { id: string }).id;
const { error } = await admin.from(tabela).update(linha as never).eq("id", id);
if (error) throw new Error(`atualizar ${tabela}: ${error.message}`);
return id;
}
const { data, error } = await admin
.from(tabela)
.insert(linha as never)
.select("id")
.single();
if (error || !data) throw new Error(`inserir ${tabela}: ${error?.message}`);
return (data as { id: string }).id;
}
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Record<string, unknown>;
const orgId = creds.org_id as string;
const crmVivo = creds.crm_vivo as { pipeline_id: string; stage_ids: Record<string, string> };
const stageId = Object.values(crmVivo.stage_ids)[0]!;
const contactId = await upsert(
"contacts",
// chave por telefone: é o identificador canônico de contato e tem unique própria
{ organization_id: orgId, phone_number: TELEFONE },
{
organization_id: orgId,
name: NOME,
display_name: NOME,
email: EMAIL,
phone_number: TELEFONE,
source: "manual",
is_anonymized: false,
anonymized_at: null,
},
);
// conversations.channel_session_id é NOT NULL: reusar a sessão que a org já tem
// (criar outra inventaria um número de WhatsApp que não existe).
const { data: sessao, error: sErr } = await admin
.from("channel_sessions")
.select("id")
.eq("organization_id", orgId)
.limit(1)
.maybeSingle();
if (sErr) throw new Error(`buscar channel_session: ${sErr.message}`);
if (!sessao) throw new Error("org sem channel_session — rode o seed de credenciais antes");
const conversationId = await upsert(
"conversations",
{ organization_id: orgId, contact_id: contactId },
{
organization_id: orgId,
contact_id: contactId,
channel_session_id: (sessao as { id: string }).id,
status: "open",
channel: "whatsapp",
},
);
const mensagens = [
{ external_id: "lgpd-e2e-1", body: `Oi, aqui é a ${NOME}, meu telefone é ${TELEFONE}`, direction: "inbound" },
{ external_id: "lgpd-e2e-2", body: "Pode me mandar o orçamento no meu e-mail?", direction: "inbound" },
{ external_id: "lgpd-e2e-3", body: "Claro, Ana! Enviando agora.", direction: "outbound" },
];
const messageIds: string[] = [];
for (const m of mensagens) {
messageIds.push(
await upsert(
"messages",
{ organization_id: orgId, external_id: m.external_id },
{
organization_id: orgId,
conversation_id: conversationId,
channel_session_id: (sessao as { id: string }).id,
contact_id: contactId,
external_id: m.external_id,
type: "text",
body: m.body,
direction: m.direction,
status: "delivered",
sent_at: new Date().toISOString(),
metadata: { origem: "seed-lgpd", telefone_bruto: TELEFONE },
},
),
);
}
const leadId = await upsert(
"crm_leads",
{ organization_id: orgId, title: LEAD_TITLE },
{
organization_id: orgId,
pipeline_id: crmVivo.pipeline_id,
stage_id: stageId,
contact_id: contactId,
title: LEAD_TITLE,
status: "open",
source: "manual",
value_cents: 250_000,
currency: "BRL",
position_in_stage: 9000,
last_activity_at: new Date().toISOString(),
},
);
// Atividade da IA: tem reason com PII (deve sumir) E evidence com ids (deve ficar).
const atividadeIa = await upsert(
"crm_lead_activities",
{ organization_id: orgId, lead_id: leadId, type: "ai_turn" },
{
organization_id: orgId,
lead_id: leadId,
contact_id: contactId,
source_module: "ai",
type: "ai_turn",
actor_kind: "ai",
reason: REASON_COM_PII,
evidence: EVIDENCE_SEM_PII,
payload: { resumo: `${NOME} pediu orçamento`, telefone: TELEFONE },
metadata: { canal: "whatsapp", contato: EMAIL },
},
);
// Atividade humana: sem reason, para provar que o cascade não inventa dado.
const atividadeHumana = await upsert(
"crm_lead_activities",
{ organization_id: orgId, lead_id: leadId, type: "note" },
{
organization_id: orgId,
lead_id: leadId,
contact_id: contactId,
source_module: "crm",
type: "note",
actor_kind: "user",
reason: null,
evidence: { trace_ids: [] },
payload: { nota: `Falei com ${NOME} por telefone` },
metadata: { origem: "seed-lgpd" },
},
);
const requestId = await upsert(
"lgpd_requests",
{ organization_id: orgId, contact_id: contactId, request_type: "redact" },
{
organization_id: orgId,
contact_id: contactId,
// O CHECK do banco aceita data_request | redact | store_redact. A UI fala
// em "customer_redact" — divergência anotada para verificação separada.
request_type: "redact",
source: "manual",
scope: "contact",
status: "received",
received_at: new Date().toISOString(),
due_at: new Date(Date.now() + 15 * 24 * 3600_000).toISOString(),
},
);
creds.lgpd = {
contact_id: contactId,
contact_name: NOME,
contact_email: EMAIL,
contact_phone: TELEFONE,
conversation_id: conversationId,
message_ids: messageIds,
lead_id: leadId,
lead_title: LEAD_TITLE,
activity_ai_id: atividadeIa,
activity_user_id: atividadeHumana,
request_id: requestId,
reason_com_pii: REASON_COM_PII,
evidence_sem_pii: EVIDENCE_SEM_PII,
};
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.info(`✅ Caso LGPD pronto:`);
console.info(` contato ${contactId} (${NOME})`);
console.info(` conversa ${conversationId} com ${messageIds.length} mensagens`);
console.info(` lead ${leadId} com 2 atividades (ai + user)`);
console.info(` request ${requestId} status=received → /app/lgpd/requests/${requestId}`);
}
main().catch((err) => {
console.error("❌ Seed LGPD falhou:", err);
process.exit(1);
});
+411
View File
@@ -0,0 +1,411 @@
/**
* Segundo tenant para o NEGATIVO de realtime (cenário 9 da §7).
*
* Sem ele, o teste de duas abas prova que o evento CHEGA — e não prova que ele
* não vaza. Realtime que atravessa tenant é pior que realtime que não funciona:
* o primeiro é vazamento de dado de cliente, o segundo é só uma feature morta.
*
* Cria org B + 1 usuário manager (senha conhecida) + pipeline com 1 lead, para
* que o board dele renderize e ASSINE o canal — uma aba que não assina não prova
* isolamento nenhum, só prova que não estava escutando.
*
* Idempotente: upsert por slug/e-mail. Grava o bloco `tenant_b` em .e2e-creds.json.
*
* Run: npx tsx scripts/seed-e2e-tenant-b.ts
*/
import { createClient } from "@supabase/supabase-js";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const ORG_SLUG = "e2e-tenant-b";
const ORG_NAME = "E2E Tenant B";
const EMAIL = "e2e-b-manager@deskcomm.test";
const PASSWORD = "E2E!Test1234";
const LEAD_TITLE = "Lead do Tenant B — nao pode vazar";
/** TOTP RFC-6238 — o produto EXIGE 2FA de admin, e a tela de LGPD é admin-only. */
function gerarTotp(secretBase32: string): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const c of secretBase32.replace(/=+$/, "").toUpperCase()) {
const idx = alphabet.indexOf(c);
if (idx >= 0) bits += idx.toString(2).padStart(5, "0");
}
const bytes = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
const counter = Buffer.alloc(8);
counter.writeUInt32BE(Math.floor(Date.now() / 1000 / 30), 4);
const hmac = crypto.createHmac("sha1", bytes).update(counter).digest();
const offset = hmac[hmac.length - 1]! & 0x0f;
return ((hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).toString().padStart(6, "0");
}
/**
* Enrola TOTP para o admin do tenant B. Sem isto o produto — corretamente —
* prende o admin na tela "sua conta exige 2FA", com o menu vazio, e nenhuma
* tela do app é alcançável. Mesma receita de scripts/seed-e2e-credentials.ts.
*/
async function ensureTotp(
userId: string,
anterior: { factor_id: string; secret: string } | null,
): Promise<{ factor_id: string; secret: string }> {
const { data: listed, error: listErr } = await admin.auth.admin.mfa.listFactors({ userId });
if (listErr) throw new Error(`listFactors: ${listErr.message}`);
const factors = listed?.factors ?? [];
if (anterior && factors.some((f) => f.id === anterior.factor_id && f.status === "verified")) {
console.info(`[seed-b] TOTP reaproveitado: ${anterior.factor_id}`);
return anterior;
}
for (const f of factors) {
const { error } = await admin.auth.admin.mfa.deleteFactor({ id: f.id, userId });
if (error) throw new Error(`deleteFactor ${f.id}: ${error.message}`);
}
const anon = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { error: signInErr } = await anon.auth.signInWithPassword({ email: EMAIL, password: PASSWORD });
if (signInErr) throw new Error(`signIn para enroll: ${signInErr.message}`);
const { data: enrolled, error: enrollErr } = await anon.auth.mfa.enroll({
factorType: "totp",
friendlyName: "e2e-tenant-b",
});
if (enrollErr || !enrolled) throw new Error(`mfa.enroll: ${enrollErr?.message}`);
const secret = enrolled.totp.secret;
const { data: challenge, error: chErr } = await anon.auth.mfa.challenge({ factorId: enrolled.id });
if (chErr || !challenge) throw new Error(`mfa.challenge: ${chErr?.message}`);
const { error: verErr } = await anon.auth.mfa.verify({
factorId: enrolled.id,
challengeId: challenge.id,
code: gerarTotp(secret),
});
if (verErr) throw new Error(`mfa.verify: ${verErr.message}`);
await anon.auth.signOut();
console.info(`[seed-b] TOTP enrolado: ${enrolled.id}`);
return { factor_id: enrolled.id, secret };
}
async function ensureOrg(): Promise<string> {
const { data: existing, error } = await admin
.from("organizations")
.select("id")
.eq("slug", ORG_SLUG)
.maybeSingle();
if (error) throw new Error(`buscar org: ${error.message}`);
if (existing) return (existing as { id: string }).id;
const { data, error: insErr } = await admin
.from("organizations")
.insert({
slug: ORG_SLUG,
display_name: ORG_NAME,
legal_name: ORG_NAME,
timezone: "America/Sao_Paulo",
locale: "pt-BR",
onboarded_at: new Date().toISOString(),
} as never)
.select("id")
.single();
if (insErr || !data) throw new Error(`criar org: ${insErr?.message}`);
console.info(`[seed-b] org criada: ${(data as { id: string }).id}`);
return (data as { id: string }).id;
}
async function ensureUser(): Promise<string> {
const { data: list } = await admin.auth.admin.listUsers({ perPage: 200 });
const existing = list.users.find((u) => u.email === EMAIL);
if (existing) {
const { error } = await admin.auth.admin.updateUserById(existing.id, { password: PASSWORD });
if (error) throw new Error(`resetar senha ${EMAIL}: ${error.message}`);
return existing.id;
}
const { data, error } = await admin.auth.admin.createUser({
email: EMAIL,
password: PASSWORD,
email_confirm: true,
user_metadata: { full_name: "E2E B Admin" },
});
if (error || !data?.user) throw new Error(`criar user ${EMAIL}: ${error?.message}`);
console.info(`[seed-b] user criado: ${data.user.id}`);
return data.user.id;
}
async function ensureMembership(userId: string, orgId: string): Promise<void> {
const { data: existing, error } = await admin
.from("user_organizations")
.select("user_id")
.eq("user_id", userId)
.eq("organization_id", orgId)
.maybeSingle();
if (error) throw new Error(`buscar membership: ${error.message}`);
if (existing) {
const { error: updErr } = await admin
.from("user_organizations")
.update({ role: "admin", revoked_at: null } as never)
.eq("user_id", userId)
.eq("organization_id", orgId);
if (updErr) throw new Error(`atualizar membership: ${updErr.message}`);
return;
}
const { error: insErr } = await admin.from("user_organizations").insert({
user_id: userId,
organization_id: orgId,
role: "admin",
accepted_at: new Date().toISOString(),
} as never);
if (insErr) throw new Error(`inserir membership: ${insErr.message}`);
}
async function ensureBoard(orgId: string): Promise<{ pipelineId: string; leadId: string }> {
// A plataforma já provisiona um pipeline default ao criar a org (e há unique
// parcial em (organization_id) where is_default). Reusar o que existe é o certo:
// criar outro colide, e duplicar pipeline padrão seria inventar um segundo funil.
const { data: pipe, error: pErr } = await admin
.from("crm_pipelines")
.select("id")
.eq("organization_id", orgId)
.eq("is_default", true)
.maybeSingle();
if (pErr) throw new Error(`buscar pipeline default: ${pErr.message}`);
if (!pipe) throw new Error(`org ${orgId} não tem pipeline default provisionado`);
const pipelineId = (pipe as { id: string }).id;
const { data: stage, error: sErr } = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", pipelineId)
.eq("is_archived", false)
.order("position")
.limit(1)
.maybeSingle();
if (sErr) throw new Error(`buscar stage: ${sErr.message}`);
if (!stage) throw new Error(`pipeline ${pipelineId} sem estágio utilizável`);
const stageId = (stage as { id: string }).id;
const { data: leadExist, error: lErr } = await admin
.from("crm_leads")
.select("id")
.eq("organization_id", orgId)
.eq("title", LEAD_TITLE)
.maybeSingle();
if (lErr) throw new Error(`buscar lead: ${lErr.message}`);
if (leadExist) return { pipelineId, leadId: (leadExist as { id: string }).id };
const { data, error } = await admin
.from("crm_leads")
.insert({
organization_id: orgId,
pipeline_id: pipelineId,
stage_id: stageId,
title: LEAD_TITLE,
status: "open",
source: "manual",
value_cents: 100_000,
currency: "BRL",
position_in_stage: 1000,
last_activity_at: new Date().toISOString(),
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`criar lead: ${error?.message}`);
return { pipelineId, leadId: (data as { id: string }).id };
}
/**
* Pegada de LGPD DESCARTÁVEL no tenant B — para ensaiar o ciclo de anonimização
* sem tocar na org compartilhada, onde um `store_redact` atingiria 55 contatos,
* 132 mensagens e 51 leads de forma irreversível.
*
* Sem mensagens de propósito: o tenant B não tem `channel_session`, e criar uma
* seria inventar um número de WhatsApp que não existe.
*/
async function ensurePegadaLgpd(
orgId: string,
pipelineId: string,
stageId: string,
): Promise<{ contactId: string; leadId: string; activityId: string; requestId: string }> {
const NOME_B = "Titular Descartavel Tenant B";
const TEL_B = "+5511960009999";
const { data: cExist, error: cErr } = await admin
.from("contacts")
.select("id")
.eq("organization_id", orgId)
.eq("phone_number", TEL_B)
.maybeSingle();
if (cErr) throw new Error(`buscar contato B: ${cErr.message}`);
const contatoLinha = {
organization_id: orgId,
name: NOME_B,
display_name: NOME_B,
email: "titular.b@descartavel.test",
phone_number: TEL_B,
source: "manual",
is_anonymized: false,
anonymized_at: null,
};
let contactId: string;
if (cExist) {
contactId = (cExist as { id: string }).id;
const { error } = await admin.from("contacts").update(contatoLinha as never).eq("id", contactId);
if (error) throw new Error(`atualizar contato B: ${error.message}`);
} else {
const { data, error } = await admin.from("contacts").insert(contatoLinha as never).select("id").single();
if (error || !data) throw new Error(`criar contato B: ${error?.message}`);
contactId = (data as { id: string }).id;
}
const { data: lExist, error: lErr } = await admin
.from("crm_leads")
.select("id")
.eq("organization_id", orgId)
.eq("title", "Ensaio LGPD — tenant B")
.maybeSingle();
if (lErr) throw new Error(`buscar lead B: ${lErr.message}`);
let leadId: string;
const leadLinha = {
organization_id: orgId,
pipeline_id: pipelineId,
stage_id: stageId,
contact_id: contactId,
title: "Ensaio LGPD — tenant B",
status: "open",
source: "manual",
value_cents: 50_000,
currency: "BRL",
position_in_stage: 2000,
last_activity_at: new Date().toISOString(),
};
if (lExist) {
leadId = (lExist as { id: string }).id;
const { error } = await admin.from("crm_leads").update(leadLinha as never).eq("id", leadId);
if (error) throw new Error(`atualizar lead B: ${error.message}`);
} else {
const { data, error } = await admin.from("crm_leads").insert(leadLinha as never).select("id").single();
if (error || !data) throw new Error(`criar lead B: ${error?.message}`);
leadId = (data as { id: string }).id;
}
const atividadeLinha = {
organization_id: orgId,
lead_id: leadId,
contact_id: contactId,
source_module: "ai",
type: "ai_turn",
actor_kind: "ai",
reason: `Titular ${NOME_B} confirmou o telefone ${TEL_B}.`,
evidence: { trace_ids: ["trace_b_001"], run_ids: ["run_b_001"] },
payload: { resumo: `${NOME_B} pediu retorno`, telefone: TEL_B },
metadata: { canal: "whatsapp" },
};
const { data: aExist, error: aErr } = await admin
.from("crm_lead_activities")
.select("id")
.eq("lead_id", leadId)
.eq("type", "ai_turn")
.maybeSingle();
if (aErr) throw new Error(`buscar atividade B: ${aErr.message}`);
let activityId: string;
if (aExist) {
activityId = (aExist as { id: string }).id;
const { error } = await admin
.from("crm_lead_activities")
.update(atividadeLinha as never)
.eq("id", activityId);
if (error) throw new Error(`atualizar atividade B: ${error.message}`);
} else {
const { data, error } = await admin
.from("crm_lead_activities")
.insert(atividadeLinha as never)
.select("id")
.single();
if (error || !data) throw new Error(`criar atividade B: ${error?.message}`);
activityId = (data as { id: string }).id;
}
// store_redact: o ÚNICO tipo que a UI conhece hoje. Aqui é seguro porque o
// tenant é descartável e só tem este titular.
const reqLinha = {
organization_id: orgId,
request_type: "store_redact",
source: "manual",
scope: "tenant",
status: "received",
received_at: new Date().toISOString(),
due_at: new Date(Date.now() + 15 * 24 * 3600_000).toISOString(),
};
const { data: rExist, error: rErr } = await admin
.from("lgpd_requests")
.select("id")
.eq("organization_id", orgId)
.eq("request_type", "store_redact")
.maybeSingle();
if (rErr) throw new Error(`buscar request B: ${rErr.message}`);
let requestId: string;
if (rExist) {
requestId = (rExist as { id: string }).id;
const { error } = await admin.from("lgpd_requests").update(reqLinha as never).eq("id", requestId);
if (error) throw new Error(`atualizar request B: ${error.message}`);
} else {
const { data, error } = await admin.from("lgpd_requests").insert(reqLinha as never).select("id").single();
if (error || !data) throw new Error(`criar request B: ${error?.message}`);
requestId = (data as { id: string }).id;
}
return { contactId, leadId, activityId, requestId };
}
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Record<string, unknown>;
const orgId = await ensureOrg();
const userId = await ensureUser();
await ensureMembership(userId, orgId);
const { pipelineId, leadId } = await ensureBoard(orgId);
const { data: stageB } = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", pipelineId)
.order("position")
.limit(1)
.maybeSingle();
const pegada = await ensurePegadaLgpd(orgId, pipelineId, (stageB as { id: string }).id);
const anterior = (creds.tenant_b as { totp?: { factor_id: string; secret: string } } | undefined)?.totp ?? null;
const totp = await ensureTotp(userId, anterior);
creds.tenant_b = {
org_id: orgId,
org_slug: ORG_SLUG,
user_id: userId,
email: EMAIL,
password: PASSWORD,
pipeline_id: pipelineId,
lead_id: leadId,
lead_title: LEAD_TITLE,
role: "admin",
totp,
lgpd: pegada,
};
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.info(`\n✅ Tenant B pronto: org=${orgId} pipeline=${pipelineId}`);
console.info(` login: ${EMAIL} / ${PASSWORD}`);
}
main().catch((err) => {
console.error("❌ Seed do tenant B falhou:", err);
process.exit(1);
});
+220
View File
@@ -0,0 +1,220 @@
/**
* ENSAIO do ciclo de anonimização — no tenant DESCARTÁVEL, não na org de teste.
*
* Objetivo: provar o INSTRUMENTO antes de precisar dele. Roda o único tipo que a
* UI conhece hoje (`store_redact`), do clique até o cascade, e verifica os dois
* lados. Quando o vocabulário do `redact` for alinhado, só troca o alvo.
*
* POR QUE NO TENANT B: `store_redact` anonimiza o TENANT INTEIRO. Na org de teste
* isso atingiria 55 contatos, 132 mensagens e 51 leads — irreversível, e
* destruiria os fixtures de todas as sessões. Aqui o tenant é meu, de hoje, e
* tem um único titular.
*
* EXECUÇÃO DO WORKER — escopo cirúrgico: não existe runner standalone do worker
* de LGPD (o único script é o genérico, que carrega os handlers de ENVIO e
* dispararia 139 eventos pendentes, alguns de abril). Então este arquivo
* seleciona **apenas** eventos `lgpd.redact_received` **desta org** e chama
* `processLgpdRedact()` neles. Não varre a fila, não toca em message.outbound,
* não "limpa" nada.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-lgpd-ensaio-tenant-b.ts
*/
import { chromium } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import type { EventRow } from "@/lib/event-log/dispatcher";
import { processLgpdRedact } from "@/workers/lgpd-redact-worker";
import { CREDS, EVIDENCE, loginAs, shotPage } from "./qa-helpers";
const envFile = fs.readFileSync(".env.local", "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const B = CREDS.tenant_b as {
org_id: string;
email: string;
password: string;
totp: { secret: string };
lgpd: { contactId: string; leadId: string; activityId: string; requestId: string };
};
const resultados: { nome: string; ok: boolean; detalhe: string }[] = [];
function record(nome: string, ok: boolean, detalhe: string): void {
resultados.push({ nome, ok, detalhe });
console.info(`${ok ? "PASS" : "FALHA"} ${nome} — ${detalhe}`);
}
async function estado() {
const c = await admin
.from("contacts")
.select("name,email,phone_number,is_anonymized")
.eq("id", B.lgpd.contactId)
.single();
if (c.error) throw new Error(`contato: ${c.error.message}`);
const a = await admin
.from("crm_lead_activities")
.select("id,actor_kind,reason,evidence,payload,metadata,created_at")
.eq("id", B.lgpd.activityId)
.single();
if (a.error) throw new Error(`atividade: ${a.error.message}`);
const r = await admin
.from("lgpd_requests")
.select("status")
.eq("id", B.lgpd.requestId)
.single();
if (r.error) throw new Error(`request: ${r.error.message}`);
return {
contato: c.data as Record<string, unknown>,
atividade: a.data as Record<string, unknown>,
status: (r.data as { status: string }).status,
};
}
/** Executa SÓ os eventos de redact desta org. Escopo é a garantia de segurança. */
async function rodarWorkerDeLgpd(): Promise<number> {
const { data, error } = await admin
.from("event_log")
.select("id,organization_id,event_type,entity_kind,entity_id,payload,metadata,consumed_by,attempts")
.eq("organization_id", B.org_id)
.eq("event_type", "lgpd.redact_received")
.eq("status", "pending");
if (error) throw new Error(`buscar eventos: ${error.message}`);
const eventos = (data ?? []) as unknown as EventRow[];
console.info(`[worker] ${eventos.length} evento(s) lgpd.redact_received pendente(s) NESTA org`);
for (const ev of eventos) {
if (ev.event_type !== "lgpd.redact_received" || ev.organization_id !== B.org_id) {
throw new Error(`[worker] recusado: evento fora do escopo (${ev.event_type} / ${ev.organization_id})`);
}
const r = await processLgpdRedact(ev);
console.info(`[worker] evento ${ev.id.slice(0, 8)} → status=${r.status} detail=${r.detail ?? "-"}`);
}
return eventos.length;
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
const antes = await estado();
console.info("=== ANTES ===");
console.info(` contato: "${antes.contato.name}" anonimizado=${antes.contato.is_anonymized}`);
console.info(` atividade: reason=${antes.atividade.reason ? "TEM PII" : "null"} evidence=${JSON.stringify(antes.atividade.evidence)} actor_kind=${antes.atividade.actor_kind}`);
console.info(` request: ${antes.status}`);
if (antes.status !== "received") {
throw new Error(`request em "${antes.status}" — rode scripts/seed-e2e-tenant-b.ts para reiniciar`);
}
// ---- o caminho do usuário -------------------------------------------------
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
page.setDefaultTimeout(90_000);
page.setDefaultNavigationTimeout(90_000);
await loginAs(page, {
email: B.email,
password: B.password,
totpSecret: B.totp.secret,
rotulo: "tenant B admin",
});
await page.getByRole("link", { name: "LGPD", exact: true }).click();
await page.waitForURL(/\/app\/lgpd/, { timeout: 30_000 });
const link = page.locator(`a[href*="${B.lgpd.requestId}"]`).first();
await link.waitFor({ state: "visible", timeout: 90_000 });
await shotPage(page, "lgpd-b-1-lista.png", false);
await link.click();
await page.waitForURL(new RegExp(B.lgpd.requestId), { timeout: 90_000 });
await page.waitForLoadState("networkidle").catch(() => null);
const botao = page.getByRole("button", { name: /anonimiz|aprovar/i }).first();
if ((await botao.count()) === 0) {
const texto = (await page.locator("main").first().textContent()) ?? "";
throw new Error(
`[lgpd-b] sem botão de aprovação — a tela diz: ${texto.replace(/\s+/g, " ").slice(0, 200)}`,
);
}
record("UI: a tela de aprovação ABRE para store_redact", true, "botão de aprovação presente");
await shotPage(page, "lgpd-b-2-detalhe.png", false);
await botao.click();
const campo = page.locator("#approved_reason, textarea").first();
await campo.waitFor({ state: "visible", timeout: 20_000 });
await campo.fill("Ensaio do ciclo de anonimizacao em tenant descartavel — QA");
const confirmar = page.getByRole("button", { name: /confirmar|anonimiz|aprovar/i }).last();
await confirmar.click();
await page.waitForTimeout(4000);
await shotPage(page, "lgpd-b-3-aprovado.png", false);
await browser.close();
const posAprovacao = await estado();
record(
"UI: aprovação transiciona o request",
posAprovacao.status !== "received",
`"${antes.status}" → "${posAprovacao.status}"`,
);
// ---- o worker, com escopo cirúrgico ---------------------------------------
const executados = await rodarWorkerDeLgpd();
record("worker de LGPD executou o evento", executados > 0, `${executados} evento(s) processado(s)`);
const depois = await estado();
console.info("\n=== DEPOIS ===");
console.info(` contato: "${depois.contato.name}" anonimizado=${depois.contato.is_anonymized}`);
// --- lado A: o que TEM de sumir ---
record(
"contato anonimizado",
depois.contato.is_anonymized === true,
`is_anonymized=${depois.contato.is_anonymized} name="${depois.contato.name}"`,
);
record(
"activity.reason virou NULL",
depois.atividade.reason === null,
`reason = ${depois.atividade.reason === null ? "NULL" : JSON.stringify(depois.atividade.reason).slice(0, 50)}`,
);
record(
"activity.payload e metadata zerados",
JSON.stringify(depois.atividade.payload ?? {}) === "{}" &&
JSON.stringify(depois.atividade.metadata ?? {}) === "{}",
`payload=${JSON.stringify(depois.atividade.payload).slice(0, 40)} metadata=${JSON.stringify(depois.atividade.metadata).slice(0, 40)}`,
);
// --- lado B: o que NÃO pode sumir ---
record(
"activity.evidence SOBREVIVEU",
JSON.stringify(depois.atividade.evidence) === JSON.stringify(antes.atividade.evidence),
`antes=${JSON.stringify(antes.atividade.evidence)} depois=${JSON.stringify(depois.atividade.evidence)}`,
);
record(
"activity.actor_kind SOBREVIVEU",
depois.atividade.actor_kind === antes.atividade.actor_kind,
`"${antes.atividade.actor_kind}" → "${depois.atividade.actor_kind}"`,
);
record(
"timestamp preservado",
depois.atividade.created_at === antes.atividade.created_at,
depois.atividade.created_at === antes.atividade.created_at ? "intacto" : "MUDOU",
);
const falhas = resultados.filter((x) => !x.ok);
console.info(`\n=== ENSAIO: ${resultados.length - falhas.length}/${resultados.length} PASS ===`);
for (const f of falhas) console.info(` FALHA ${f.nome}: ${f.detalhe}`);
if (falhas.length) process.exit(1);
}
main().catch((err) => {
console.error("❌ Ensaio de LGPD falhou:", err);
process.exit(1);
});
+222
View File
@@ -0,0 +1,222 @@
/**
* LGPD — prova do REDACT pelo caminho do usuário (a tela), não pelo RPC.
*
* Prova de banco mostra que a função roda. Prova na tela mostra que o caminho
* que o usuário usa CHEGA até ela. São coisas diferentes, e só a segunda vale
* como garantia de produto.
*
* Verifica os DOIS lados — porque um redact que apaga demais é tão errado
* quanto um que apaga de menos:
*
* DEVE SUMIR DEVE SOBREVIVER
* contato anonimizado activity.actor_kind (fato de auditoria)
* mensagens: body/metadata activity.evidence (só ids, não é PII)
* activity.payload/metadata timestamps (histórico preservado)
* activity.reason (texto de LLM)
*
* Run: E2E_PORT=3020 npx tsx tests/capture-lgpd-redact.ts
*/
import { chromium } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import { CREDS, EVIDENCE, login, shotPage } from "./qa-helpers";
const envFile = fs.readFileSync(".env.local", "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const L = CREDS.lgpd as {
contact_id: string;
contact_name: string;
contact_phone: string;
lead_id: string;
activity_ai_id: string;
request_id: string;
};
const resultados: { nome: string; ok: boolean; detalhe: string }[] = [];
function record(nome: string, ok: boolean, detalhe: string): void {
resultados.push({ nome, ok, detalhe });
console.info(`${ok ? "PASS" : "FALHA"} ${nome} — ${detalhe}`);
}
async function estado() {
const c = await admin
.from("contacts")
.select("name,email,phone_number,is_anonymized")
.eq("id", L.contact_id)
.single();
if (c.error) throw new Error(`contato: ${c.error.message}`);
const m = await admin
.from("messages")
.select("id,body,metadata")
.eq("contact_id", L.contact_id);
if (m.error) throw new Error(`mensagens: ${m.error.message}`);
const a = await admin
.from("crm_lead_activities")
.select("id,type,actor_kind,reason,evidence,payload,metadata,created_at")
.eq("lead_id", L.lead_id);
if (a.error) throw new Error(`atividades: ${a.error.message}`);
const r = await admin.from("lgpd_requests").select("status").eq("id", L.request_id).single();
if (r.error) throw new Error(`request: ${r.error.message}`);
return {
contato: c.data as Record<string, unknown>,
mensagens: m.data as { id: string; body: string | null; metadata: unknown }[],
atividades: a.data as Record<string, unknown>[],
status: (r.data as { status: string }).status,
};
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
const antes = await estado();
console.info("=== ANTES ===");
console.info(` contato: name="${antes.contato.name}" anonimizado=${antes.contato.is_anonymized}`);
console.info(` mensagens: ${antes.mensagens.length}, com body: ${antes.mensagens.filter((x) => x.body).length}`);
const aiAntes = antes.atividades.find((x) => x.id === L.activity_ai_id)!;
console.info(` atividade IA: reason=${aiAntes.reason ? "TEM PII" : "null"} evidence=${JSON.stringify(aiAntes.evidence)} actor_kind=${aiAntes.actor_kind}`);
console.info(` request: ${antes.status}`);
if (antes.status !== "received") {
throw new Error(`request está em "${antes.status}" — rode scripts/seed-e2e-lgpd.ts para reiniciar o caso`);
}
// ---- o caminho do usuário --------------------------------------------------
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
// Dev server compila por rota na primeira visita (40-80s). Paciência maior
// aqui é ambiente, não produto — timeout curto viraria falso vermelho.
page.setDefaultTimeout(90_000);
page.setDefaultNavigationTimeout(90_000);
await login(page, "admin");
await page.getByRole("link", { name: "LGPD", exact: true }).click();
await page.waitForURL(/\/app\/lgpd/, { timeout: 20_000 });
// O dev server compila a rota na primeira visita: esperar a TABELA existir,
// não o screenshot. Sem isso, a foto sai em branco e eu acusaria tela quebrada.
// Esperar o LINK da solicitação, não uma linha qualquer: as primeiras linhas
// da tabela são esqueleto de carregamento e passariam por "conteúdo pronto".
const linkSolicitacao = page.locator(`a[href*="${L.request_id}"]`).first();
await linkSolicitacao.waitFor({ state: "visible", timeout: 90_000 });
console.info(`[nav] clicou em LGPD → ${page.url()} (solicitação ${L.request_id.slice(0, 8)} na lista)`);
await shotPage(page, "lgpd-1-lista-requests.png", false);
// Abre pelo CLIQUE na linha da solicitação — o caminho do usuário.
// A lista mostra o titular como "ctt:<prefixo>", não pelo nome — então o alvo
// é o link da própria solicitação.
const linha = linkSolicitacao;
if ((await linha.count()) === 0) {
const texto = (await page.locator("main").first().textContent()) ?? "";
throw new Error(
`[lgpd] a solicitação não aparece na lista para ser clicada.\nLista diz: ${texto.replace(/\s+/g, " ").slice(0, 240)}`,
);
}
await linha.click();
await page.waitForURL(new RegExp(L.request_id), { timeout: 90_000 });
await page.waitForLoadState("networkidle").catch(() => null);
await shotPage(page, "lgpd-2-detalhe-request.png", false);
const botao = page.getByRole("button", { name: /anonimiz|aprovar|confirmar/i }).first();
if ((await botao.count()) === 0) {
const texto = (await page.locator("main").first().textContent()) ?? "";
throw new Error(
`[lgpd] nenhum botão de aprovação na tela do request.\nTela diz: ${texto.replace(/\s+/g, " ").slice(0, 200)}`,
);
}
await botao.click();
const campo = page.locator("#approved_reason, textarea, input[type='text']").first();
await campo.waitFor({ state: "visible", timeout: 10_000 });
await campo.fill("Titular solicitou anonimizacao por e-mail em 24/07 — teste E2E do ciclo");
await shotPage(page, "lgpd-3-confirmacao.png", false);
const confirmar = page.getByRole("button", { name: /confirmar|anonimiz/i }).last();
await confirmar.click();
await page.waitForTimeout(3000);
await shotPage(page, "lgpd-4-apos-aprovar.png", false);
// ---- espera o cascade acontecer -------------------------------------------
const limite = Date.now() + 45_000;
let depois = await estado();
while (Date.now() < limite && !depois.contato.is_anonymized) {
await page.waitForTimeout(3000);
depois = await estado();
}
await browser.close();
console.info("\n=== DEPOIS ===");
console.info(` request: ${depois.status}`);
console.info(` contato: name="${depois.contato.name}" anonimizado=${depois.contato.is_anonymized}`);
record(
"UI: aprovação muda o request de received para processing",
depois.status !== "received",
`status "${antes.status}" → "${depois.status}"`,
);
const aiDepois = depois.atividades.find((x) => x.id === L.activity_ai_id);
// --- lado A: o que TEM de sumir ---
record(
"contato anonimizado",
depois.contato.is_anonymized === true && depois.contato.name !== L.contact_name,
`is_anonymized=${depois.contato.is_anonymized} name="${depois.contato.name}"`,
);
const comBody = depois.mensagens.filter((x) => x.body).length;
record(
"mensagens: body e metadata zerados",
comBody === 0,
`${comBody} de ${depois.mensagens.length} mensagens ainda com body`,
);
record(
"activity.reason virou NULL (texto de LLM pode ter PII)",
aiDepois?.reason === null,
`reason = ${aiDepois?.reason === null ? "NULL" : JSON.stringify(aiDepois?.reason).slice(0, 60)}`,
);
const payloadVazio =
JSON.stringify(aiDepois?.payload ?? {}) === "{}" &&
JSON.stringify(aiDepois?.metadata ?? {}) === "{}";
record(
"activity.payload e metadata zerados",
payloadVazio,
`payload=${JSON.stringify(aiDepois?.payload).slice(0, 50)} metadata=${JSON.stringify(aiDepois?.metadata).slice(0, 50)}`,
);
// --- lado B: o que NÃO pode sumir (o teste que muita gente esquece) ---
record(
"activity.evidence SOBREVIVEU (só ids, não é PII)",
JSON.stringify(aiDepois?.evidence) === JSON.stringify(aiAntes.evidence),
`antes=${JSON.stringify(aiAntes.evidence)} depois=${JSON.stringify(aiDepois?.evidence)}`,
);
record(
"activity.actor_kind SOBREVIVEU (fato de auditoria)",
aiDepois?.actor_kind === aiAntes.actor_kind,
`antes="${aiAntes.actor_kind}" depois="${aiDepois?.actor_kind}"`,
);
record(
"timestamps preservados (histórico não some)",
aiDepois?.created_at === aiAntes.created_at,
`created_at ${aiDepois?.created_at === aiAntes.created_at ? "intacto" : "MUDOU"}`,
);
const falhas = resultados.filter((x) => !x.ok);
console.info(`\n=== LGPD: ${resultados.length - falhas.length}/${resultados.length} PASS ===`);
for (const f of falhas) console.info(` FALHA ${f.nome}: ${f.detalhe}`);
if (falhas.length) process.exit(1);
}
main().catch((err) => {
console.error("❌ Prova de LGPD falhou:", err);
process.exit(1);
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Wave 0 — captura da linha de base visual do board CRM Vivo.
*
* Navega como usuário real (login pela tela, cliques até o board) e grava os
* PNGs em evidence/ na raiz do worktree (§7 do briefing). É contra estas fotos
* que a Wave 2 (o slot) vai ser julgada.
*
* Usa `tests/qa-helpers.ts` — mesma máquina de login, navegação e recorte de
* card dos capturadores das waves seguintes. Antes este arquivo tinha cópia
* própria dessas funções, com o locator antigo `getByRole("heading")`, que
* quebra por corrida de hidratação (o card é `role="button"` e o `<h3>` perde
* o papel de heading). Uma cópia divergente é o começo do teste que mente.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-0.ts
*/
import { chromium } from "@playwright/test";
import * as fs from "node:fs";
import { CARD_ATTR, EVIDENCE, gotoBoard, login, shotCard, shotPage } from "./qa-helpers";
const USER = process.env.E2E_USER ?? "manager";
/** Título do card de 123 caracteres — trunca na tela, então casa por prefixo. */
const LONG_TITLE_PREFIX = "Clínica Vitalis — pacote completo";
const MAIN_CARD = "Clínica Vitalis — implantes";
/** Todos os cards do seed. A variação de altura entre eles é a métrica da Wave 2. */
const CARDS = [
MAIN_CARD,
LONG_TITLE_PREFIX,
"Marina Costa — clareamento",
"Rogério Paiva — avaliação inicial",
"Família Andrade — 4 tratamentos",
"Bruno Tavares — protocolo superior",
"Helena Marques — ortodontia adulto",
"Grupo Odonto Sul — contrato corporativo",
"Caio Ribeiro — dor de dente",
"Patrícia Nunes — prótese fixa",
];
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
const consoleErrors: string[] = [];
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text());
});
await login(page, USER);
await gotoBoard(page);
await shotPage(page, "wave-0-board-antes.png");
await shotCard(page, MAIN_CARD, "wave-0-card-antes.png");
await shotCard(page, LONG_TITLE_PREFIX, "wave-0-card-titulo-longo-antes.png");
// Medidas por ferramenta, nunca a olho (§7). A altura constante é a promessa
// da Wave 2 — aqui fica registrado quanto ela varia HOJE.
const metrics = await page.evaluate(
({ titulos, attr }: { titulos: string[]; attr: string }) => {
const out: { title: string; width: number; height: number }[] = [];
const cards = Array.from(document.querySelectorAll(`[${attr}]`));
for (const titulo of titulos) {
for (const card of cards) {
if (!(card.textContent ?? "").includes(titulo)) continue;
const r = card.getBoundingClientRect();
out.push({
title: titulo.slice(0, 36),
width: Math.round(r.width),
height: Math.round(r.height),
});
break;
}
}
return out;
},
{ titulos: CARDS, attr: CARD_ATTR },
);
console.info("[medida] altura de cada card (px):");
for (const m of metrics) console.info(` ${String(m.height).padStart(4)} ${m.title}`);
const hs = metrics.map((m) => m.height);
console.info(
`[medida] altura min=${Math.min(...hs)} max=${Math.max(...hs)} variacao=${Math.max(...hs) - Math.min(...hs)}px em ${hs.length} cards`,
);
console.info(
consoleErrors.length
? `[console] ${consoleErrors.length} erro(s): ${consoleErrors.slice(0, 3).map((e) => e.slice(0, 120)).join(" | ")}`
: "[console] sem erros",
);
await browser.close();
console.info("\n✅ Wave 0 — evidência capturada em evidence/");
}
main().catch((err) => {
console.error("❌ Captura Wave 0 falhou:", err);
process.exit(1);
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Wave 1 — prova do caminho de ATRIBUIÇÃO EM MASSA sobre lead de dono AGENTE.
*
* Este é o caso que, antes do helper `resolveOwnerPatch`, quebrava de dois
* jeitos: mandar só `owner_user_id` sobre um lead de dono agente viola
* `crm_leads_owner_kind_coherence` (23514) e **derruba o lote inteiro**; e um
* lead que ganha dono sem `owner_kind` vira drift silencioso.
*
* Prova na tela: selecionar o card do agente, atribuir a um humano pelo lote,
* e verificar que a operação conclui e o card troca de dono. Ao fim, devolve o
* lead ao agente rodando o seed — o fixture sai como entrou.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-1-bulk.ts
*/
import { chromium } from "@playwright/test";
import { cardLocator, gotoBoard, login, shotPage } from "./qa-helpers";
const LEAD_AGENTE = "Rogério Paiva — avaliação inicial";
/**
* O menu de LOTE só oferece "Eu" e "Remover responsável" — nem outros humanos,
* nem agentes. Então o alvo é "Eu", que é o próprio manager logado. Isso ainda
* exercita o caminho que quebrava: humano sobre lead de dono AGENTE.
*/
const ITEM_MENU = "Eu";
const HUMANO = "E2E Manager";
async function main(): Promise<void> {
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
const erros: string[] = [];
const http400: string[] = [];
page.on("console", (m) => {
if (m.type() === "error") erros.push(m.text());
});
page.on("response", (r) => {
if (r.status() >= 400 && !r.url().includes("/monitoring")) {
http400.push(`${r.status()} ${r.url().slice(0, 90)}`);
}
});
await login(page, "manager");
await gotoBoard(page);
const { card } = await cardLocator(page, LEAD_AGENTE);
const antes = await card.textContent();
console.info(`[antes] card: ${(antes ?? "").replace(/\s+/g, " ").slice(0, 80)}`);
// seleciona o card (clique simples seleciona; ctrl/meta acumula)
await card.click();
const barra = page.getByText(/\d+ selecionado/);
await barra.waitFor({ state: "visible", timeout: 10_000 });
console.info(`[selecao] ${await barra.textContent()}`);
await shotPage(page, "wave-1-bulk-selecionado.png", false);
await page.getByRole("button", { name: /Atribuir a/ }).click();
const alvo = page.getByRole("menuitem", { name: ITEM_MENU, exact: true }).first();
if ((await alvo.count()) === 0) {
const itens = await page.getByRole("menuitem").allTextContents();
throw new Error(
`[bulk] item "${ITEM_MENU}" não aparece no menu de atribuição em massa.\n` +
`Itens presentes: ${itens.map((t) => `"${t}"`).join(", ")}`,
);
}
await alvo.click();
await page.waitForTimeout(2500);
const depois = await cardLocator(page, LEAD_AGENTE);
const estado = await depois.card.evaluate((el: Element) => {
const badge = el.querySelector("[aria-label^='Responsável:']");
const avatar = badge?.querySelector("span[aria-hidden]");
const cls = avatar?.className ?? "";
return {
aria: badge?.getAttribute("aria-label") ?? null,
preenchido: /bg-accent-soft/.test(cls),
vazadoComAnel: /border/.test(cls) && /ring/.test(cls),
};
});
const ok =
estado.aria === `Responsável: ${HUMANO}` && estado.preenchido && !estado.vazadoComAnel;
console.info(
`${ok ? "PASS" : "FALHA"} lote atribuiu humano sobre lead de dono AGENTE — aria="${estado.aria}" preenchido=${estado.preenchido} vazadoComAnel=${estado.vazadoComAnel}`,
);
console.info(` HTTP >=400 durante a operação: ${http400.length ? http400.join(" | ") : "nenhum"}`);
console.info(` erros de console: ${erros.length ? erros.slice(0, 2).join(" | ") : "nenhum"}`);
await shotPage(page, "wave-1-bulk-apos-atribuir.png", false);
await browser.close();
if (!ok || http400.length > 0) process.exit(1);
}
main().catch((err) => {
console.error("❌ Prova do lote falhou:", err);
process.exit(1);
});
+376
View File
@@ -0,0 +1,376 @@
/**
* Wave 1 — CORE 1: a IA é dona do negócio. Prova visual dos 4 cenários da §7.
*
* 1. Atribuir lead a um agente pela UI → avatar VAZADO COM ANEL + tooltip
* "Nome · vN". Falha se aparecer "Sem responsável" ou emoji.
* 2. Filtro "Responsável" lista agentes JUNTO dos humanos e filtra certo.
* 3. Transferir agente→humano e humano→agente; ambos persistem após reload.
* 4. `viewer` NÃO consegue reatribuir (RBAC intacto).
*
* Mais axe-core (sem violação nova) e a foto do board para o teste do metro.
*
* O lead usado volta a "Sem responsável" no fim, pela própria UI — o fixture
* sai como entrou.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-1.ts
*/
import AxeBuilder from "@axe-core/playwright";
import { chromium, type Page } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import * as path from "node:path";
import { CREDS, EVIDENCE, cardLocator, gotoBoard, login, shotCard, shotPage } from "./qa-helpers";
/** Lead do seed que nasce sem dono — é ele que exercita as transferências. */
const LEAD = "Marina Costa — clareamento";
/** Donos-agente que o seed já cria (Wave 1). Cobrem os DOIS casos de tooltip. */
const LEAD_AGENTE_COM_VERSAO = "Rogério Paiva — avaliação inicial"; // Lia — AgendaPlus · v24
const LEAD_AGENTE_SEM_VERSAO = "Caio Ribeiro — dor de dente"; // Bot Padrão E2E, sem versão
const results: { n: number; nome: string; ok: boolean; detalhe: string }[] = [];
function record(n: number, nome: string, ok: boolean, detalhe: string): void {
results.push({ n, nome, ok, detalhe });
console.info(`${ok ? "PASS" : "FALHA"} [cenário ${n}] ${nome} — ${detalhe}`);
}
/** Agente destino: mesma regra do endpoint /assignable (não arquivado + ativo). */
async function pickAgent(): Promise<{ id: string; name: string; version: number | null }> {
const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { data, error } = await admin
.from("ai_agents")
.select("id,name,published_version_id,is_active")
.eq("organization_id", CREDS.org_id)
.is("archived_at", null)
.eq("is_active", true)
.order("priority", { ascending: false })
.order("created_at", { ascending: true })
.limit(1);
if (error) throw new Error(`ai_agents: ${error.message}`);
const agent = (data ?? [])[0] as { id: string; name: string; published_version_id: string | null };
if (!agent) throw new Error("nenhum agente ativo e não-arquivado na org — cenário 1 impossível");
let version: number | null = null;
if (agent.published_version_id) {
const { data: v } = await admin
.from("ai_agent_versions")
.select("version_number")
.eq("id", agent.published_version_id)
.maybeSingle();
version = (v as { version_number: number } | null)?.version_number ?? null;
}
return { id: agent.id, name: agent.name, version };
}
/** Abre o menu ⋮ do card e entra no submenu "Responsável". */
async function openOwnerMenu(page: Page, title: string): Promise<void> {
const { card } = await cardLocator(page, title);
await card.hover();
const trigger = card.getByRole("button", { name: "Ações do lead" });
if ((await trigger.count()) === 0) {
throw new Error(`[cenário] card "${title}" sem botão "Ações do lead"`);
}
await trigger.click();
const sub = page.getByRole("menuitem", { name: "Responsável" });
await sub.waitFor({ state: "visible", timeout: 10_000 });
await sub.click();
}
/** Estado do dono desenhado no card: forma do avatar + rótulo + tooltip. */
async function ownerState(page: Page, title: string) {
const { card } = await cardLocator(page, title);
return card.evaluate((el: Element) => {
const txt = el.textContent ?? "";
const badge = el.querySelector("[aria-label^='Responsável:']");
const avatar = badge?.querySelector("span[aria-hidden]");
const cls = avatar?.className ?? "";
return {
semResponsavel: /Sem responsável/.test(txt),
tooltip: badge?.getAttribute("title") ?? null,
ariaLabel: badge?.getAttribute("aria-label") ?? null,
iniciais: avatar?.textContent?.trim() ?? null,
// agente = círculo vazado (border + ring, fundo transparente)
vazadoComAnel: /border/.test(cls) && /ring/.test(cls),
preenchido: /bg-accent-soft/.test(cls),
temEmoji: /\p{Extended_Pictographic}/u.test(txt),
temBadgeAI: /\bAI\b/.test(txt),
};
});
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
const agent = await pickAgent();
console.info(
`[setup] agente destino: "${agent.name}"${agent.version != null ? ` v${agent.version}` : " (sem versão publicada)"}`,
);
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
await login(page, "manager");
await gotoBoard(page);
// ---- Cenário 1a: os donos-agente que o seed já criou ---------------------
// Dois tooltips, dois casos: agente COM versão publicada e agente SEM.
const comVersao = await ownerState(page, LEAD_AGENTE_COM_VERSAO);
record(
1,
`dono-agente do seed com versão publicada (${LEAD_AGENTE_COM_VERSAO.slice(0, 20)}…)`,
comVersao.vazadoComAnel && / · v\d+$/.test(comVersao.tooltip ?? "") && !comVersao.temEmoji && !comVersao.temBadgeAI,
`vazadoComAnel=${comVersao.vazadoComAnel} tooltip="${comVersao.tooltip}" emoji=${comVersao.temEmoji} badgeAI=${comVersao.temBadgeAI}`,
);
await shotCard(page, LEAD_AGENTE_COM_VERSAO, "wave-1-cenario-1-agente-com-versao.png");
const semVersao = await ownerState(page, LEAD_AGENTE_SEM_VERSAO);
const tooltipSemVersao = semVersao.tooltip ?? "";
record(
1.2 as unknown as number,
"agente SEM versão publicada: tooltip só com o nome, sem ' · v' pendurado nem 'undefined'",
semVersao.vazadoComAnel &&
!/ · v/.test(tooltipSemVersao) &&
!/undefined|null|NaN/i.test(tooltipSemVersao) &&
tooltipSemVersao.length > 0,
`tooltip="${tooltipSemVersao}" vazadoComAnel=${semVersao.vazadoComAnel}`,
);
await shotCard(page, LEAD_AGENTE_SEM_VERSAO, "wave-1-cenario-1-agente-sem-versao.png");
// O agente é PAR do humano (§5): mesmo diâmetro, não um enfeite menor.
// Sem função nomeada dentro do evaluate: o esbuild injeta um helper `__name`
// que não existe no navegador e o script quebra com ReferenceError.
const dims = await page.evaluate((titulos: string[]) => {
const out: ({ w: number; h: number; peso: string } | null)[] = [];
const cards = Array.from(document.querySelectorAll("[data-rfd-draggable-id]"));
for (const titulo of titulos) {
let achado: { w: number; h: number; peso: string } | null = null;
for (const card of cards) {
if (!(card.textContent ?? "").includes(titulo)) continue;
const av = card.querySelector("[aria-label^='Responsável:'] span[aria-hidden]");
if (!av) break;
const r = av.getBoundingClientRect();
achado = {
w: Math.round(r.width),
h: Math.round(r.height),
peso: getComputedStyle(av).fontWeight,
};
break;
}
out.push(achado);
}
return { agente: out[0]!, humano: out[1]! };
}, [LEAD_AGENTE_COM_VERSAO, "Clínica Vitalis — implantes"]);
record(
1.3 as unknown as number,
"avatar do agente tem MESMO tamanho e peso do humano (§5)",
!!dims.agente &&
!!dims.humano &&
dims.agente.w === dims.humano.w &&
dims.agente.h === dims.humano.h &&
dims.agente.peso === dims.humano.peso,
`agente=${JSON.stringify(dims.agente)} humano=${JSON.stringify(dims.humano)}`,
);
// ---- Cenário 2: filtro lista agentes junto dos humanos --------------------
// Roda ANTES de qualquer atribuição nova: assim "filtrar por Lia devolve só o
// Rogério" é uma afirmação sobre o seed, não sobre o que o teste acabou de criar.
const ownerFilter = page.getByRole("button", { name: /^Responsável:/ });
await ownerFilter.click();
const menuItens = await page.getByRole("menuitem").allTextContents();
const temHumano = menuItens.some((t) => /E2E (Admin|Manager|Agent)/.test(t));
const temAgente = menuItens.some((t) => t.includes(agent.name));
record(
2,
"filtro lista agentes JUNTO dos humanos",
temHumano && temAgente,
`humano=${temHumano} agente=${temAgente} · itens: ${menuItens.join(" | ").slice(0, 170)}`,
);
// "Junto" não é só "ambos aparecem no mesmo menu": um separador entre o último
// humano e o primeiro agente SEGREGA a lista, e a asserção acima passaria feliz.
// Este é o teste que reprova a segregação — o anterior, sozinho, era frouxo.
const segregacao = await page.evaluate((nomeAgente: string) => {
const itens = Array.from(
document.querySelectorAll('[role="menuitem"], [role="separator"]'),
);
let ultimoHumano = -1;
let primeiroAgente = -1;
itens.forEach((el, i) => {
const t = (el.textContent ?? "").trim();
if (el.getAttribute("role") !== "menuitem") return;
// O item agora traz o avatar junto do nome ("EA" + "E2E Admin"), então a
// detecção casa por INCLUSÃO. "Eu" é opção meta (fica acima da lista de
// pessoas) e não conta como humano da lista.
if (t === "Eu") return;
if (/E2E (Admin|Manager|Agent)/.test(t)) ultimoHumano = i;
if (primeiroAgente === -1 && t.includes(nomeAgente)) primeiroAgente = i;
});
if (ultimoHumano === -1 || primeiroAgente === -1) {
return { medivel: false, separadoresEntre: -1, ordem: itens.map((e) => (e.textContent ?? "").trim().slice(0, 18)) };
}
const inicio = Math.min(ultimoHumano, primeiroAgente);
const fim = Math.max(ultimoHumano, primeiroAgente);
const separadoresEntre = itens
.slice(inicio + 1, fim)
.filter((e) => e.getAttribute("role") === "separator").length;
return { medivel: true, separadoresEntre, ordem: [] as string[] };
}, agent.name);
record(
2.2 as unknown as number,
"sem separador segregando agentes dos humanos (a lista é MESMA de verdade)",
segregacao.medivel && segregacao.separadoresEntre === 0,
segregacao.medivel
? `separadores entre o último humano e o primeiro agente: ${segregacao.separadoresEntre}`
: `não deu para medir — ordem do menu: ${segregacao.ordem.join(" | ")}`,
);
await shotPage(page, "wave-1-cenario-2-filtro-aberto.png", false);
await page.getByRole("menuitem").filter({ hasText: agent.name }).first().click();
await page.waitForTimeout(1200);
const visiveis = await page.locator("[data-rfd-draggable-id]").allTextContents();
const temRogerio = visiveis.some((t) => t.includes(LEAD_AGENTE_COM_VERSAO));
const temCaio = visiveis.some((t) => t.includes(LEAD_AGENTE_SEM_VERSAO));
const temHumanoLead = visiveis.some((t) => t.includes("Grupo Odonto Sul"));
record(
2.1 as unknown as number,
`filtrar por "${agent.name}" devolve SÓ os leads dele`,
temRogerio && !temCaio && !temHumanoLead,
`Rogério(Lia)=${temRogerio} · Caio(OUTRO agente)=${temCaio} · lead humano=${temHumanoLead}`,
);
await shotPage(page, "wave-1-cenario-2-filtrado-por-agente.png");
// volta o filtro para Todos
await page.getByRole("button", { name: /^Responsável:/ }).click();
await page.getByRole("menuitem", { name: "Todos" }).click();
await page.waitForTimeout(800);
// ---- Cenário 1b: atribuir a um agente pela UI ----------------------------
await openOwnerMenu(page, LEAD);
const agentItem = page.getByRole("menuitem").filter({ hasText: agent.name }).first();
if ((await agentItem.count()) === 0) {
throw new Error(`[cenário 1] agente "${agent.name}" não aparece no submenu Responsável`);
}
await agentItem.click();
await page.waitForTimeout(1500); // mutação + refetch do board
const s1 = await ownerState(page, LEAD);
const tooltipEsperado = agent.version != null ? `${agent.name} · v${agent.version}` : agent.name;
record(
1.1 as unknown as number,
"atribuir a agente PELA UI: card vira avatar vazado com anel + tooltip certo",
s1.vazadoComAnel &&
!s1.semResponsavel &&
!s1.temEmoji &&
!s1.temBadgeAI &&
s1.tooltip === tooltipEsperado,
`vazadoComAnel=${s1.vazadoComAnel} semResponsavel=${s1.semResponsavel} emoji=${s1.temEmoji} badgeAI=${s1.temBadgeAI} tooltip="${s1.tooltip}"`,
);
await shotCard(page, LEAD, "wave-1-cenario-1-card-agente.png");
// ---- Cenário 3: transferir agente→humano e humano→agente ------------------
await openOwnerMenu(page, LEAD);
const humano = page.getByRole("menuitem").filter({ hasText: "E2E Manager" }).first();
if ((await humano.count()) === 0) throw new Error("[cenário 3] humano não listado no submenu");
await humano.click();
await page.waitForTimeout(1500);
await page.reload();
await page.waitForLoadState("networkidle").catch(() => null);
const s3a = await ownerState(page, LEAD);
record(
3,
"transferência agente→humano persiste após reload",
s3a.preenchido && !s3a.vazadoComAnel && !s3a.semResponsavel,
`preenchido=${s3a.preenchido} vazadoComAnel=${s3a.vazadoComAnel} aria="${s3a.ariaLabel}"`,
);
await shotCard(page, LEAD, "wave-1-cenario-3-dono-humano.png");
await openOwnerMenu(page, LEAD);
await page.getByRole("menuitem").filter({ hasText: agent.name }).first().click();
await page.waitForTimeout(1500);
await page.reload();
await page.waitForLoadState("networkidle").catch(() => null);
const s3b = await ownerState(page, LEAD);
record(
3.1 as unknown as number,
"transferência humano→agente persiste após reload",
s3b.vazadoComAnel && !s3b.semResponsavel,
`vazadoComAnel=${s3b.vazadoComAnel} tooltip="${s3b.tooltip}"`,
);
await shotCard(page, LEAD, "wave-1-cenario-3-dono-agente.png");
// ---- axe-core + foto do board (teste do metro) ---------------------------
const axe = await new AxeBuilder({ page }).analyze();
const graves = axe.violations.filter((v) => v.impact === "critical" || v.impact === "serious");
record(
5 as unknown as number,
"axe-core sem violação nova",
graves.length === 0,
graves.length === 0
? `${axe.violations.length} violação(ões) leve(s), 0 grave`
: graves.map((v) => `${v.impact}:${v.id}(${v.nodes.length})`).join(", "),
);
await shotPage(page, "wave-1-board-com-agente.png");
// ---- Cenário 4: viewer não reatribui -------------------------------------
const viewerCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const viewerPage = await viewerCtx.newPage();
await login(viewerPage, "viewer");
await gotoBoard(viewerPage);
const { card: viewerCard } = await cardLocator(viewerPage, LEAD);
await viewerCard.hover();
const viewerTrigger = viewerCard.getByRole("button", { name: "Ações do lead" });
let submenuVisivel = false;
if ((await viewerTrigger.count()) > 0) {
await viewerTrigger.click();
submenuVisivel = await viewerPage
.getByRole("menuitem", { name: "Responsável" })
.isVisible()
.catch(() => false);
}
record(
4,
"viewer NÃO consegue reatribuir",
!submenuVisivel,
submenuVisivel
? "submenu Responsável VISÍVEL para viewer — RBAC furado"
: `menu ${(await viewerTrigger.count()) > 0 ? "abre, mas sem submenu Responsável" : "nem existe para viewer"}`,
);
await shotPage(viewerPage, "wave-1-cenario-4-viewer.png", false);
await viewerCtx.close();
// ---- devolve o fixture ao estado do seed, pela própria UI ----------------
await openOwnerMenu(page, LEAD);
await page.getByRole("menuitem", { name: "Sem responsável" }).click();
await page.waitForTimeout(1500);
const sFinal = await ownerState(page, LEAD);
record(
6 as unknown as number,
'fixture restaurado para "Sem responsável" pela UI',
sFinal.semResponsavel,
`semResponsavel=${sFinal.semResponsavel}`,
);
await browser.close();
const falhas = results.filter((r) => !r.ok);
console.info(`\n=== WAVE 1: ${results.length - falhas.length}/${results.length} PASS ===`);
if (falhas.length) {
for (const f of falhas) console.info(` FALHA [${f.n}] ${f.nome}: ${f.detalhe}`);
process.exit(1);
}
}
main().catch((err) => {
console.error("❌ Wave 1 falhou:", err);
process.exit(1);
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Wave 2 — o slot: gate de ALTURA CONSTANTE do card (Lei B da §5).
*
* Critério, sem margem: `Set(alturas de todos os cards).size === 1`.
* O card não pode crescer com o dado — título de 2 linhas, valor ausente, tag a
* mais ou a menos não podem mudar a altura.
*
* A mensagem de falha NUNCA é um número solto: lista cada altura distinta com os
* cards que a têm, para dizer *o que* faz o card crescer, não só *que* cresce.
*
* Linha de base da Wave 0 (medida, não estimada): min 116 · max 154 · 38px de
* variação em 10 cards. É esse 38 que a Wave 2 tem de zerar.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-2.ts
*/
import AxeBuilder from "@axe-core/playwright";
import { chromium, type Page } from "@playwright/test";
import * as fs from "node:fs";
import { CARD_ATTR, EVIDENCE, gotoBoard, login, shotPage } from "./qa-helpers";
const USER = process.env.E2E_USER ?? "manager";
const BASELINE_VARIACAO_PX = 38;
interface CardMedida {
titulo: string;
altura: number;
largura: number;
elementos: number;
}
async function medirCards(page: Page): Promise<CardMedida[]> {
// O dev server recompila enquanto alguém edita o card: o board remonta e some
// por instantes. Esperar o card reaparecer é determinístico; medir sem esperar
// produz "board vazio" que é artefato de ambiente, não defeito do produto.
await page
.locator(`[${CARD_ATTR}]`)
.first()
.waitFor({ state: "visible", timeout: 20_000 });
const medidas = await page.evaluate((attr: string) => {
const out: { titulo: string; altura: number; largura: number; elementos: number }[] = [];
for (const card of Array.from(document.querySelectorAll(`[${attr}]`))) {
const r = card.getBoundingClientRect();
const h3 = card.querySelector("h3");
// "Elementos" = filhos diretos com caixa visível, o orçamento da Lei B.
let elementos = 0;
for (const filho of Array.from(card.children)) {
const fr = filho.getBoundingClientRect();
if (fr.width > 0 && fr.height > 0) elementos++;
}
out.push({
titulo: (h3?.textContent ?? card.textContent ?? "").trim().slice(0, 44),
altura: Math.round(r.height),
largura: Math.round(r.width),
elementos,
});
}
return out;
}, CARD_ATTR);
if (medidas.length === 0) {
throw new Error(`[gate] nenhum card [${CARD_ATTR}] no board — nada a medir`);
}
return medidas;
}
/** Agrupa por altura para a mensagem dizer O QUE faz o card crescer. */
function relatorioAlturas(cards: CardMedida[]): string {
const porAltura = new Map<number, string[]>();
for (const c of cards) {
porAltura.set(c.altura, [...(porAltura.get(c.altura) ?? []), c.titulo]);
}
return [...porAltura.entries()]
.sort((a, b) => a[0] - b[0])
.map(([h, titulos]) => ` ${String(h).padStart(4)}px — ${titulos.join(" · ")}`)
.join("\n");
}
/**
* Medidas reais da linha de base da Wave 0 — as 5 alturas distintas medidas no
* board antes do slot. Servem ao `DEMO=1`, que imprime a mensagem de falha sem
* precisar do navegador: o formato do relatório fica auditável mesmo quando o
* board está fora do ar ou já corrigido.
*/
const BASELINE_WAVE_0: CardMedida[] = [
{ titulo: "Marina Costa — clareamento", altura: 116, largura: 302, elementos: 4 },
{ titulo: "Rogério Paiva — avaliação inicial", altura: 119, largura: 302, elementos: 4 },
{ titulo: "Clínica Vitalis — implantes", altura: 143, largura: 302, elementos: 5 },
{ titulo: "Família Andrade — 4 tratamentos", altura: 143, largura: 302, elementos: 5 },
{ titulo: "Bruno Tavares — protocolo superior", altura: 143, largura: 302, elementos: 5 },
{ titulo: "Caio Ribeiro — dor de dente", altura: 143, largura: 302, elementos: 5 },
{ titulo: "Patrícia Nunes — prótese fixa", altura: 143, largura: 302, elementos: 5 },
{ titulo: "Helena Marques — ortodontia adulto", altura: 145, largura: 302, elementos: 5 },
{ titulo: "Grupo Odonto Sul — contrato corporativo", altura: 154, largura: 302, elementos: 5 },
{ titulo: "Clínica Vitalis — pacote completo de implant", altura: 154, largura: 302, elementos: 5 },
];
/** A mensagem de falha do gate. Nunca um número solto: altura → quais cards. */
function mensagemFalha(cards: CardMedida[]): string {
const alturas = new Set(cards.map((c) => c.altura));
const min = Math.min(...alturas);
const max = Math.max(...alturas);
return (
`FALHA [gate altura] ${alturas.size} alturas distintas em ${cards.length} cards — esperado 1.\n` +
` variação ${max - min}px (min ${min}, max ${max}); linha de base da Wave 0 era ${BASELINE_VARIACAO_PX}px\n` +
relatorioAlturas(cards)
);
}
async function main(): Promise<void> {
// `DEMO=1`: imprime a mensagem de falha sobre a linha de base medida da Wave 0,
// sem navegador. Prova o FORMATO do relatório quando não há board para reprovar.
if (process.env.DEMO === "1") {
console.info("=== como o gate reprova (dados reais da linha de base da Wave 0) ===");
console.info(mensagemFalha(BASELINE_WAVE_0));
return;
}
fs.mkdirSync(EVIDENCE, { recursive: true });
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
await login(page, USER);
await gotoBoard(page);
// AUTOTESTE DO INSTRUMENTO (`SELFCHECK=1`): infla um card por CSS e exige que
// o gate REPROVE. Um gate que só foi visto passando não é gate — é carimbo.
// Isto não toca código de produção: é estilo injetado na página do teste.
if (process.env.SELFCHECK === "1") {
await page.addStyleTag({
content: `[${CARD_ATTR}]:first-of-type { padding-bottom: 40px !important; }`,
});
await page.waitForTimeout(300);
const cardsFalsos = await medirCards(page);
const alturasFalsas = new Set(cardsFalsos.map((c) => c.altura));
const reprovou = alturasFalsas.size > 1;
console.info(
reprovou
? `PASS [autoteste] o gate REPROVA quando as alturas divergem — ${alturasFalsas.size} alturas distintas detectadas:\n${relatorioAlturas(cardsFalsos)}`
: `FALHA [autoteste] inflei um card e o gate NÃO percebeu — o instrumento está cego`,
);
await browser.close();
process.exit(reprovou ? 0 : 1);
}
await shotPage(page, "wave-2-board-depois.png");
const cards = await medirCards(page);
const alturas = new Set(cards.map((c) => c.altura));
const min = Math.min(...alturas);
console.info(`[medida] ${cards.length} cards no board`);
console.info(relatorioAlturas(cards));
const alturaConstante = alturas.size === 1;
console.info(
alturaConstante
? `PASS [gate altura] altura constante: ${min}px em ${cards.length} cards (linha de base era ${BASELINE_VARIACAO_PX}px de variação)`
: mensagemFalha(cards),
);
// Lei B também fixa o orçamento de elementos: 5, contados como filhos diretos
// visíveis do card. Reportado junto porque é o outro eixo do mesmo contrato.
const acimaDoOrcamento = cards.filter((c) => c.elementos > 5);
console.info(
acimaDoOrcamento.length === 0
? `PASS [gate elementos] nenhum card acima de 5 elementos (máx observado: ${Math.max(...cards.map((c) => c.elementos))})`
: `FALHA [gate elementos] ${acimaDoOrcamento.length} card(s) acima de 5:\n` +
acimaDoOrcamento.map((c) => ` ${c.elementos} elementos — ${c.titulo}`).join("\n"),
);
// Cenário 7 da §7: o card de borda (título de 123 caracteres, valor nulo e 8
// tags) não pode quebrar o layout — mesma altura dos demais, sem vazar largura.
const bordas = [
{ nome: "título de 123 caracteres", chave: "Clínica Vitalis — pacote completo" },
{ nome: "valor nulo", chave: "Rogério Paiva" },
{ nome: "8 tags", chave: "Família Andrade" },
];
const larguraPadrao = cards[0]!.largura;
const quebras: string[] = [];
for (const b of bordas) {
const c = cards.find((x) => x.titulo.includes(b.chave.slice(0, 22)));
if (!c) {
quebras.push(`${b.nome}: card não encontrado no board`);
continue;
}
if (c.altura !== min) quebras.push(`${b.nome}: altura ${c.altura}px ≠ ${min}px dos demais`);
if (c.largura !== larguraPadrao) {
quebras.push(`${b.nome}: largura ${c.largura}px ≠ ${larguraPadrao}px dos demais`);
}
}
console.info(
quebras.length === 0
? `PASS [cenário 7] título de 123 chars, valor nulo e 8 tags não quebram o layout (todos ${min}×${larguraPadrao}px)`
: `FALHA [cenário 7] ${quebras.length} quebra(s):\n${quebras.map((q) => ` ${q}`).join("\n")}`,
);
// axe-core: o número que o regente quer com build novo. Era 11 nós serious.
const axe = await new AxeBuilder({ page }).analyze();
const graves = axe.violations.filter(
(v) => v.impact === "critical" || v.impact === "serious",
);
const nested = axe.violations.find((v) => v.id === "nested-interactive");
console.info(
graves.length === 0
? `PASS [axe] zero violação serious/critical (nested-interactive: ${nested ? `${nested.nodes.length} nós` : "ZERADO, era 11"})`
: `FALHA [axe] ${graves.length} violação(ões) grave(s): ` +
graves.map((v) => `${v.impact}:${v.id}(${v.nodes.length} nós)`).join(", "),
);
await browser.close();
if (!alturaConstante || acimaDoOrcamento.length > 0 || quebras.length > 0 || graves.length > 0) {
process.exit(1);
}
}
main().catch((err) => {
console.error("❌ Gate da Wave 2 falhou:", err);
process.exit(1);
});
+957
View File
@@ -0,0 +1,957 @@
/**
* Wave 3 — cenários 10, 11 e 12 da §7.
*
* 10. Ação do agente → atividade nasce com ator = AGENTE e `reason` VISÍVEL.
* 11. Uma decisão de NÃO enviar aparece na timeline. Silêncio do agente também é evento.
* 12. O card PULSA UMA VEZ e para. Falha se ficar animando.
*
* Os cenários 10 e 11 dependem de superfície que o @DevVivo ainda está escrevendo
* (blocos 2.2 a 2.4). Este arquivo FALHA ALTO dizendo exatamente o que falta, em
* vez de passar por omissão — teste que "passa" porque a tela não existe é o pior
* tipo de verde.
*
* O cenário 12 já é mensurável: com o realtime entregando, dá para amostrar o card
* e provar que a animação acontece UMA vez e cessa.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-3-cenarios.ts
*/
import { chromium, type Page } from "@playwright/test";
import * as fs from "node:fs";
import { createClient } from "@supabase/supabase-js";
import { createHash } from "node:crypto";
import { BASE, CARD_ATTR, CREDS, EVIDENCE, carimbar, gotoBoard, login, loginAs, shotPage } from "./qa-helpers";
/** Hash dos bytes do recorte do card: prova que a IMAGEM mudou, não que algo rodou. */
const hashDoRecorte = (b: Buffer): string => createHash("sha1").update(b).digest("hex").slice(0, 12);
const envFile = fs.readFileSync(".env.local", "utf8");
const envVars: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) envVars[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(envVars.NEXT_PUBLIC_SUPABASE_URL!, envVars.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
const ALVO = "Marina Costa — clareamento";
/**
* Três estados, não dois. REPROVADO acusa quem construiu; BLOQUEADO acusa quem
* planejou. Chamar de reprovado um recurso que ninguém escreveu ainda transfere
* culpa para o lado errado e polui o placar com dívida de plano.
*/
type Estado = "PASS" | "FALHA" | "BLOQUEADO";
const resultados: { n: string; nome: string; estado: Estado; detalhe: string }[] = [];
function record(n: string, nome: string, ok: boolean, detalhe: string, estado?: Estado): void {
const e: Estado = estado ?? (ok ? "PASS" : "FALHA");
resultados.push({ n, nome, estado: e, detalhe });
console.info(`${e.padEnd(9)} [cenário ${n}] ${nome} — ${detalhe}`);
}
/**
* Avalia uma SUPERFÍCIE de timeline pelos 4 critérios do cenário 10.
*
* "Renderizou algum item" passaria numa tela que despeja uuid no rosto do
* usuário — é o pior tipo de verde. Então a asserção é sobre TEXTO.
*/
async function avaliarSuperficie(
page: Page,
seletor: string,
): Promise<{
texto: string;
temMotivoHumano: boolean;
uuidsVisiveis: string[];
temRotuloCru: boolean;
atorDistinguivel: boolean;
}> {
const dados = await page.evaluate((sel: string) => {
// O painel VISÍVEL, não o primeiro: a página tem dois tabpanels e o primeiro
// é o oculto. Ler o oculto devolve texto vazio — e aí "não há uuid" e "não há
// rótulo cru" passariam por AUSÊNCIA, que é o verde vácuo de novo.
const candidatos = Array.from(document.querySelectorAll(sel)) as HTMLElement[];
const raiz = candidatos.find((x) => x.offsetParent !== null) ?? candidatos[0] ?? document.body;
const texto = raiz.innerText ?? "";
// Ator: o BRIEFING §5 e o OwnerBadge são explícitos — humano é disco
// PREENCHIDO, agente é círculo VAZADO COM ANEL. A distinção é geométrica.
const preenchidos = raiz.querySelectorAll("[class*='bg-accent-soft']").length;
const comAnel = raiz.querySelectorAll("[class*='ring-'][class*='border-accent']").length;
return { texto, preenchidos, comAnel };
}, seletor);
const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
return {
texto: dados.texto.replace(/\s+/g, " "),
temMotivoHumano: /Movido de .+ para .+/i.test(dados.texto),
uuidsVisiveis: [...new Set(dados.texto.match(uuid) ?? [])],
temRotuloCru: /\b(stage_changed|ai_turn|note|lead_created)\b/.test(dados.texto),
atorDistinguivel: dados.preenchidos > 0 || dados.comAnel > 0,
};
}
/**
* Conta INÍCIOS de animação no card — não presença de classe.
*
* A distinção é o coração do 12.d: se a classe fica no elemento, ela está
* presente nos dois casos (um pulso ou dois), e a asserção passaria igual. O
* evento `animationstart` só dispara quando a animação REINICIA de verdade,
* que é exatamente o que "dois pulsos visíveis" quer dizer.
*/
async function armarContadorDePulsos(page: Page, titulo: string): Promise<void> {
await page.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
(window as unknown as { __pulsos: number }).__pulsos = 0;
document.addEventListener(
"animationstart",
(ev: Event) => {
const alvoEv = ev.target as Element | null;
const card = alvoEv?.closest?.(`[${attr}]`);
if (card && (card.textContent ?? "").includes(alvo)) {
(window as unknown as { __pulsos: number }).__pulsos++;
// No INSTANTE zero o estilo computado ainda é o base (sem sombra):
// o quadro do keyframe ainda não pintou. Espero DOIS quadros de
// animação — continua sendo de dentro da página, sem corrida com a
// rede, e já pega o valor interpolado.
const nomeAnim = (ev as AnimationEvent).animationName;
const elAnimado = alvoEv as Element;
const paiCard = card as HTMLElement;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const cs = getComputedStyle(elAnimado);
(window as unknown as { __pulsoInfo: unknown }).__pulsoInfo = {
animacao: nomeAnim,
sombra: cs.boxShadow,
fundo: cs.backgroundColor,
paiRecorta: getComputedStyle(paiCard).overflow,
};
});
});
}
},
true,
);
},
{ attr: CARD_ATTR, alvo: titulo },
);
}
async function lerPulsos(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { __pulsos?: number }).__pulsos ?? 0);
}
/** Duas mudanças remotas seguidas no MESMO lead, com intervalo controlado. */
async function duasMudancasRemotas(leadId: string, estagios: string[], intervaloMs: number): Promise<void> {
const a1 = await admin.from("crm_leads").update({ stage_id: estagios[0] } as never).eq("id", leadId);
if (a1.error) throw new Error(`1a mudança: ${a1.error.message}`);
await new Promise((r) => setTimeout(r, intervaloMs));
const a2 = await admin.from("crm_leads").update({ stage_id: estagios[1] } as never).eq("id", leadId);
if (a2.error) throw new Error(`2a mudança: ${a2.error.message}`);
}
/**
* Mede o overlay do pulso pelo que o OLHO receberia, não pelo contador.
*
* Dois riscos concretos, ambos verificáveis por estilo computado:
* (a) ANEL RECORTADO — sombra desenhada para FORA de um overlay que preenche o
* card, dentro de um pai com overflow-hidden, é cortada: o contador sobe e
* a tela não muda. Sombra `inset` desenha para DENTRO e sobrevive.
* (b) COBERTOR SÓLIDO — overlay com fundo opaco sobre o card inteiro deixa o
* usuário sem ler o conteúdo. Aviso tem de ser anel, não cobertor.
*/
async function medirOverlay(page: Page, titulo: string) {
return page.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const card of Array.from(document.querySelectorAll(`[${attr}]`))) {
if (!(card.textContent ?? "").includes(alvo)) continue;
const ov = card.querySelector("[data-pulse]") as HTMLElement | null;
const csCard = getComputedStyle(card as Element);
if (!ov) {
return {
existe: false,
contador: null as string | null,
sombra: "",
fundo: "",
paiRecorta: csCard.overflow,
};
}
const cs = getComputedStyle(ov);
return {
existe: true,
contador: ov.getAttribute("data-pulse"),
sombra: cs.boxShadow,
fundo: cs.backgroundColor,
paiRecorta: csCard.overflow,
};
}
return { existe: false, contador: null as string | null, sombra: "", fundo: "", paiRecorta: "" };
},
{ attr: CARD_ATTR, alvo: titulo },
);
}
/**
* O overlay VIVE apenas durante o pulso — medir depois encontra nada e diria
* "não há aviso" sobre um aviso que existiu. Então: dispara e persegue, lendo
* rápido até apanhá-lo no ar.
*/
async function capturarOverlayDurante(
page: Page,
titulo: string,
janelaMs: number,
): Promise<Awaited<ReturnType<typeof medirOverlay>> | null> {
// Não basta apanhar o overlay: ele VIVE 1200ms, mas a animação dura 320ms.
// Pegá-lo depois da animação devolve sombra "none" — e eu concluiria que o
// aviso não é desenhado, quando ele só já tinha terminado. Persigo até achar
// a sombra ACESA; guardo a última vista como consolo para a mensagem de erro.
const limite = Date.now() + janelaMs;
let ultima: Awaited<ReturnType<typeof medirOverlay>> | null = null;
while (Date.now() < limite) {
const m = await medirOverlay(page, titulo);
if (m.existe) {
ultima = m;
if (m.sombra && m.sombra !== "none") return m;
}
}
return ultima;
}
/** Fundo é "cobertor" quando é opaco o bastante para esconder o texto do card. */
function ehCobertor(fundo: string): boolean {
if (!fundo || /transparent/i.test(fundo)) return false;
const m = fundo.match(/rgba?\(([^)]+)\)/);
if (!m) return false;
const partes = m[1]!.split(",").map((x) => parseFloat(x.trim()));
const alfa = partes.length >= 4 ? partes[3]! : 1;
return alfa > 0.4;
}
/** Em que coluna (droppable) o card está — usado nas duas abas. */
async function colunaDe(page: Page, titulo: string): Promise<string | null> {
return page.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const c of Array.from(document.querySelectorAll(`[${attr}]`))) {
if ((c.textContent ?? "").includes(alvo)) {
return c.closest("[data-rfd-droppable-id]")?.getAttribute("data-rfd-droppable-id") ?? "?";
}
}
return null;
},
{ attr: CARD_ATTR, alvo: titulo },
);
}
/** Move o card pelo teclado — interação real, sem a fragilidade do arrasto de mouse. */
async function moverPorTeclado(page: Page, titulo: string): Promise<boolean> {
const antes = await colunaDe(page, titulo);
for (const direcao of ["ArrowRight", "ArrowLeft"]) {
const card = page.locator(`[${CARD_ATTR}]`).filter({ hasText: titulo }).first();
await card.scrollIntoViewIfNeeded();
await card.focus();
await page.keyboard.press("Space");
await page.waitForTimeout(400);
await page.keyboard.press(direcao);
await page.waitForTimeout(400);
await page.keyboard.press("Space");
await page.waitForTimeout(1200);
if ((await colunaDe(page, titulo)) !== antes) return true;
}
// Sem coluna livre em nenhuma direção: a AÇÃO não aconteceu, e quem chamou
// precisa saber disso para não culpar a entrega por um gatilho que não houve.
return false;
}
/**
* Amostra a assinatura visual do card ao longo do tempo.
*
* "Pulsa uma vez" é uma afirmação sobre a SÉRIE TEMPORAL, não sobre um instante:
* uma foto não distingue "está pulsando agora" de "vai pulsar para sempre". Por
* isso o detector grava a sequência de estados e conta as TRANSIÇÕES.
*/
async function amostrarPulso(
page: Page,
titulo: string,
duracaoMs: number,
): Promise<{
amostras: string[];
transicoes: number;
episodios: number;
assentou: boolean;
terminouLimpo: boolean;
}> {
const amostras: string[] = [];
const fim = Date.now() + duracaoMs;
while (Date.now() < fim) {
const assinatura = await page.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const card of Array.from(document.querySelectorAll(`[${attr}]`))) {
if (!(card.textContent ?? "").includes(alvo)) continue;
const cs = getComputedStyle(card);
// Assinatura = o que o olho veria mudar: anel/sombra, fundo e animação.
return [
`anim:${cs.animationName}`,
`sombra:${cs.boxShadow.slice(0, 60)}`,
`fundo:${cs.backgroundColor}`,
`contorno:${cs.outlineWidth}/${cs.outlineColor}`,
`classe:${card.className.replace(/\s+/g, " ").slice(-70)}`,
].join("|");
}
return "AUSENTE";
},
{ attr: CARD_ATTR, alvo: titulo },
);
amostras.push(assinatura);
await page.waitForTimeout(150);
}
let transicoes = 0;
for (let i = 1; i < amostras.length; i++) {
if (amostras[i] !== amostras[i - 1]) transicoes++;
}
// "Pulsa UMA vez" não é "muda uma vez": uma transição suave, amostrada a cada
// 150ms, produz vários estados intermediários enquanto a cor interpola. Contar
// transições cruas puniria justamente a animação bem-feita.
// O que define "uma vez" é o número de EPISÓDIOS — quantas vezes o card SAIU
// do repouso. Um pulso = uma saída e um retorno, com quantos passos forem.
const repouso = amostras[0];
let episodios = 0;
let dentro = false;
for (const a of amostras) {
if (a !== repouso && !dentro) {
episodios++;
dentro = true;
} else if (a === repouso) {
dentro = false;
}
}
const cauda = amostras.slice(-4);
const assentou = cauda.every((a) => a === repouso);
return {
amostras,
transicoes,
episodios,
assentou,
terminouLimpo: amostras[amostras.length - 1] === repouso,
};
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
// Declara de QUE arquivos esta prova depende. Obriga a escrever a cadeia
// causal antes de medir — e, com a árvore suja, o resultado nasce marcado
// como não-veredito em vez de parecer um.
const sufixoCarimbo = carimbar([
"app/globals.css",
"components/kanban/KanbanCard.tsx",
"components/kanban/KanbanBoard.tsx",
"components/contacts/TimelineView.tsx",
"components/inbox/CRMSidePanel.tsx",
"hooks/kanban/useBoard.ts",
"lib/leads/activity-vocabulary.ts",
]);
const browser = await chromium.launch();
// Contextos independentes: a aba B tem de receber do SERVIDOR, não compartilhar
// estado de cliente com a aba A.
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const abaA = await ctxA.newPage();
const abaB = await ctxB.newPage();
/**
* O token do canal não pode ser cacheável: um proxy que guardasse a resposta
* serviria o token de um usuário para outro. Isso NÃO é provável por fora —
* sem sessão o middleware devolve o 401 dele, e todo teste externo mede o
* middleware, nunca o handler. Com sessão real, dá para ler o header que
* chegou na conexão — a diferença entre "está escrito no código" e "chega".
*/
const respostasToken: { status: number; cacheControl: string | null }[] = [];
for (const pg of [abaA, abaB]) {
pg.on("response", (r) => {
if (r.url().includes("/api/v1/auth/realtime-token")) {
respostasToken.push({
status: r.status(),
cacheControl: r.headers()["cache-control"] ?? null,
});
}
});
}
await login(abaA, "manager");
await gotoBoard(abaA);
await login(abaB, "manager");
await gotoBoard(abaB);
// ABA C — outro tenant. Sem ela o teste prova entrega e NÃO prova isolamento,
// e realtime que atravessa tenant é vazamento de dado de cliente, não bug de UX.
const B_TENANT = CREDS.tenant_b as {
email: string;
password: string;
totp: { secret: string };
pipeline_id: string;
};
const ctxC = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const abaC = await ctxC.newPage();
await loginAs(abaC, {
email: B_TENANT.email,
password: B_TENANT.password,
totpSecret: B_TENANT.totp.secret,
rotulo: "outro tenant",
});
await abaC.goto(`${BASE}/app/pipelines/${B_TENANT.pipeline_id}`);
await abaC
.locator(`[${CARD_ATTR}]`)
.first()
.waitFor({ state: "visible", timeout: 60_000 });
console.info(`[aba C] board do outro tenant carregado (${await abaC.locator(`[${CARD_ATTR}]`).count()} card)`);
// ---- cenário 12: pulsa UMA vez ------------------------------------------
const linhaBase = await amostrarPulso(abaB, ALVO, 1500);
console.info(`[base] estado de repouso: "${linhaBase.amostras[0]}" (${linhaBase.transicoes} transições em repouso)`);
// Onde o card está, na aba B, ANTES de mexer — para separar "o pulso não existe"
// de "o evento nem chegou". São diagnósticos diferentes e mandam o dev a lugares
// diferentes; um teste que junta os dois é pior que nenhum teste.
const colunaAntesB = await abaB.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const c of Array.from(document.querySelectorAll(`[${attr}]`))) {
if ((c.textContent ?? "").includes(alvo)) {
return c.closest("[data-rfd-droppable-id]")?.getAttribute("data-rfd-droppable-id") ?? "?";
}
}
return null;
},
{ attr: CARD_ATTR, alvo: ALVO },
);
// Onde o card está na aba A antes da ação — sem isto, um arrasto que não
// engatou seria reportado como "o evento não chegou", mandando o dev caçar
// realtime quando o problema foi a AÇÃO não ter acontecido.
const colunaAntesA = await colunaDe(abaA, ALVO);
// Arma o contador ANTES da ação: pulso que acontece antes de eu observar é
// pulso perdido, e eu concluiria "não pulsou".
await armarContadorDePulsos(abaB, ALVO);
const acaoAconteceu = await moverPorTeclado(abaA, ALVO);
const colunaDepoisA = await colunaDe(abaA, ALVO);
record(
"12.a",
"a AÇÃO aconteceu na aba A (o arrasto por teclado engatou)",
acaoAconteceu,
acaoAconteceu
? `card mudou de coluna na origem`
: `o card NÃO saiu do lugar na aba A — o arrasto não engatou; nada do que vem depois é conclusivo`,
);
const durante = await amostrarPulso(abaB, ALVO, 6000);
const pulsosAposEvento = await lerPulsos(abaB);
// Janela EXTRA: se novas animações começarem depois que tudo deveria ter
// cessado, é loop. Zerar o contador e olhar de novo separa "pulsou" de "não para".
await abaB.evaluate(() => {
(window as unknown as { __pulsos: number }).__pulsos = 0;
});
await abaB.waitForTimeout(4000);
const pulsosTardios = await lerPulsos(abaB);
const colunaDepoisB = await abaB.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const c of Array.from(document.querySelectorAll(`[${attr}]`))) {
if ((c.textContent ?? "").includes(alvo)) {
return c.closest("[data-rfd-droppable-id]")?.getAttribute("data-rfd-droppable-id") ?? "?";
}
}
return null;
},
{ attr: CARD_ATTR, alvo: ALVO },
);
const eventoChegou = !!colunaDepoisB && colunaDepoisB !== colunaAntesB;
record(
"12.0",
"pré-condição: o evento CHEGOU na aba B sem reload",
eventoChegou,
eventoChegou
? `card mudou de coluna na aba B (${colunaAntesB?.slice(0, 8)} → ${colunaDepoisB?.slice(0, 8)})`
: `o card NÃO mudou de coluna na aba B — sem gatilho, o cenário 12 é INCONCLUSIVO, não reprovado`,
);
// INSTRUMENTO TROCADO: amostrar a cada 150ms NÃO enxerga uma animação de 320ms
// — cada leitura custa uma ida e volta ao navegador, então o intervalo efetivo
// supera a própria animação. Medir assim produzia vermelho que era defeito do
// MEU instrumento, não do produto (o 12.d, que já contava inícios, mostrava o
// pulso funcionando na mesma rodada). `animationstart` conta exatamente o que
// importa: quantas vezes a animação COMEÇOU.
const pulsou = pulsosAposEvento >= 1;
record(
"12",
"o card pulsa quando a mudança vem de FORA",
eventoChegou && pulsou,
eventoChegou
? `${pulsosAposEvento} início(s) de animação após a chegada remota`
: "gatilho não chegou nesta rodada — inconclusivo",
);
record(
"12.1",
"pulsa e CESSA (não fica reanimando sozinho)",
pulsou && pulsosTardios === 0,
`${pulsosAposEvento} início(s) na chegada · ${pulsosTardios} novo(s) início(s) na janela seguinte` +
(pulsosTardios > 0 ? " — está reanimando sozinho" : " — cessou"),
);
await shotPage(abaB, "wave-3-c12-apos-pulso.png", false);
// ---- 12.c: ação LOCAL não pulsa ------------------------------------------
// O card se move sob o cursor e já tem retorno próprio; pulsar aí é ruído com
// cara de novidade. Ajo e meço na MESMA aba.
await armarContadorDePulsos(abaA, ALVO);
await moverPorTeclado(abaA, ALVO);
await abaA.waitForTimeout(3000);
const pulsosLocais = await lerPulsos(abaA);
record(
"12.c",
"ação LOCAL não pulsa (o pulso anuncia o que vem de FORA)",
pulsosLocais === 0,
pulsosLocais === 0
? "nenhum início de animação na aba que agiu"
: `${pulsosLocais} pulso(s) na própria aba — ruído com cara de novidade`,
);
// ---- 12.d: duas mudanças remotas = DOIS pulsos ---------------------------
// Conta INÍCIOS de animação: presença de classe passaria nos dois casos.
const leadAlvo = await admin
.from("crm_leads")
.select("id,stage_id")
.eq("pipeline_id", CREDS.crm_vivo.pipeline_id)
.ilike("title", `${ALVO.slice(0, 18)}%`)
.maybeSingle();
if (leadAlvo.error || !leadAlvo.data) {
record("12.d", "duas mudanças remotas produzem dois pulsos", false, "lead alvo não encontrado para o gatilho duplo");
} else {
const estagiosRes = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", CREDS.crm_vivo.pipeline_id)
.eq("is_archived", false)
.order("position")
.limit(3);
const ids = ((estagiosRes.data ?? []) as { id: string }[]).map((x) => x.id);
const atual = (leadAlvo.data as { id: string; stage_id: string }).stage_id;
const destinos = ids.filter((x) => x !== atual).slice(0, 2);
await armarContadorDePulsos(abaB, ALVO);
// Dispara a 1a mudança e JÁ sai perseguindo o overlay, antes que ele suma.
const cardB = abaB.locator(`[${CARD_ATTR}]`).filter({ hasText: ALVO }).first();
await duasMudancasRemotas((leadAlvo.data as { id: string }).id, destinos, 500);
// PERNA DE PIXEL: o evento nativo prova que ALGO rodou; só o pixel prova que
// alguém VERIA. E o A/B é DURANTE × DEPOIS, nunca durante × antes: o gatilho
// também muda o dado do card (coluna, ordem), então comparar com o repouso
// anterior teria duas variáveis e não isolaria o aviso.
let durantePix = "";
let depoisPix = "";
try {
durantePix = hashDoRecorte(await cardB.screenshot());
} catch {
durantePix = "(não capturado)";
}
await abaB.waitForTimeout(5000);
try {
depoisPix = hashDoRecorte(await cardB.screenshot());
} catch {
depoisPix = "(não capturado)";
}
const ovDurante = (await abaB.evaluate(
() =>
(window as unknown as {
__pulsoInfo?: { animacao: string; sombra: string; fundo: string; paiRecorta: string };
}).__pulsoInfo ?? null,
)) as { animacao: string; sombra: string; fundo: string; paiRecorta: string } | null;
const pulsosRemotos = await lerPulsos(abaB);
record(
"12.d",
"DUAS mudanças remotas seguidas produzem DOIS pulsos",
pulsosRemotos >= 2,
pulsosRemotos >= 2
? `${pulsosRemotos} inícios de animação — a segunda chegada foi anunciada`
: `${pulsosRemotos} início(s) de animação para 2 mudanças — a segunda foi ENGOLIDA: chegou coisa de fora e a tela não disse nada`,
);
// SEGUNDA PERNA do 12.d: o contador prova que o overlay REMONTOU, não que o
// usuário VIU. Com a sombra recortada pelo pai, o número sobe e a tela fica
// igual. Então: o aviso precisa ser desenhado PARA DENTRO (inset) e não pode
// ser um cobertor opaco por cima do card.
// O NOME da animação NÃO distingue as hipóteses: seria idêntico numa versão
// que não desenha nada (foi o que aconteceu com a versão de sombra inset,
// medida como invisível pelo próprio autor). Sinal adjacente não é prova —
// minha própria lei. Quem decide aqui é o pixel.
const pixelMudou =
durantePix !== depoisPix &&
!durantePix.startsWith("(") &&
!depoisPix.startsWith("(");
const cobertor = ehCobertor(ovDurante?.fundo ?? "");
record(
"12.d.visivel",
"o aviso é VISTO (o pixel do card muda durante o pulso) e não é cobertor",
pixelMudou && !cobertor,
`pixel durante=${durantePix} · depois=${depoisPix} · mudou=${pixelMudou}` +
` · animação="${ovDurante?.animacao ?? "-"}" (informativo, não decide)` +
` · fundo="${ovDurante?.fundo ?? "-"}" (cobertor=${cobertor})`,
);
await shotPage(abaB, "wave-3-c12d-duas-chegadas.png", false);
}
// ---- 12.rm: MOVIMENTO REDUZIDO -------------------------------------------
// A §5 é explícita: com prefers-reduced-motion o pulso vira mudança ESTÁTICA
// de fundo — "não é opcional". Duas exigências, e a segunda é a que quase todo
// mundo esquece: sem animação E o estado continua legível por outro meio.
//
// TRAVA CONTRA VERDE VAZIO: enquanto não existir pulso, "não animou" é
// trivialmente verdadeiro. Então este critério só CONTA quando o pulso existir;
// até lá sai como BLOQUEADO, igual ao 12.
const ctxRM = await browser.newContext({
viewport: { width: 1440, height: 900 },
reducedMotion: "reduce",
});
const abaRM = await ctxRM.newPage();
await login(abaRM, "manager");
await gotoBoard(abaRM);
const repousoRM = await amostrarPulso(abaRM, ALVO, 1200);
const colunaRMAntes = await colunaDe(abaRM, ALVO);
// Gatilho PRÓPRIO: depender do movimento feito noutra aba tornava este
// critério refém do timing alheio — falhava por causa do gatilho, não do
// comportamento sob teste. Cada critério dispara o que precisa medir.
await armarContadorDePulsos(abaRM, ALVO);
const alvoRM = await admin
.from("crm_leads")
.select("id,stage_id")
.eq("pipeline_id", CREDS.crm_vivo.pipeline_id)
.ilike("title", `${ALVO.slice(0, 18)}%`)
.maybeSingle();
const estagiosRM = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", CREDS.crm_vivo.pipeline_id)
.eq("is_archived", false)
.order("position")
.limit(3);
const destinoRM = ((estagiosRM.data ?? []) as { id: string }[])
.map((x) => x.id)
.find((x) => x !== (alvoRM.data as { stage_id: string } | null)?.stage_id);
if (alvoRM.data && destinoRM) {
const mv = await admin
.from("crm_leads")
.update({ stage_id: destinoRM } as never)
.eq("id", (alvoRM.data as { id: string }).id);
if (mv.error) throw new Error(`gatilho rm: ${mv.error.message}`);
}
const duranteRM = await amostrarPulso(abaRM, ALVO, 6000);
const colunaRMDepois = await colunaDe(abaRM, ALVO);
const pulsosRM = await lerPulsos(abaRM);
// Com movimento reduzido: NENHUMA animação deve iniciar (contador nativo, não
// amostragem) — e a chegada precisa continuar perceptível por outro meio.
const animouComRM = pulsosRM > 0;
const estadoLegivel = !!colunaRMDepois && colunaRMDepois !== colunaRMAntes;
record(
"12.rm",
"com movimento reduzido: sem animação E o estado continua legível",
!animouComRM && estadoLegivel,
pulsou
? `animou=${animouComRM} · mudança perceptível sem animação=${estadoLegivel}`
: "sem pulso implementado, este critério ainda não distingue nada",
pulsou ? undefined : "BLOQUEADO",
);
console.info(` [rm] repouso="${repousoRM.amostras[0]?.slice(0, 28)}" transições=${duranteRM.transicoes}`);
await shotPage(abaRM, "wave-3-c12rm-movimento-reduzido.png", false);
await ctxRM.close();
// ---- NEGATIVO obrigatório: o outro tenant não recebe nada ----------------
const vazouCard = (await abaC.locator(`[${CARD_ATTR}]`).allTextContents()).some((t) =>
t.includes(ALVO),
);
record(
"9-neg",
"outro tenant NÃO enxerga o lead da org A",
!vazouCard,
vazouCard ? "VAZOU — lead de outra org no board do tenant B" : "board do outro tenant só com dados dele",
);
// E também não pode PULSAR por evento alheio: um card que reage a evento de
// outro tenant denuncia assinatura mal filtrada mesmo sem mostrar o dado.
const cardC = ((await abaC.locator(`[${CARD_ATTR}]`).first().textContent()) ?? "").slice(0, 40);
const pulsoC = await amostrarPulso(abaC, cardC.trim().slice(0, 20), 3000);
record(
"12-neg",
"card do outro tenant NÃO pulsa por evento alheio",
pulsoC.transicoes === 0,
`${pulsoC.transicoes} transição(ões) no board do outro tenant`,
);
await shotPage(abaC, "wave-3-c12-outro-tenant.png", false);
// ---- token do canal: chegou com no-store? --------------------------------
const ok200 = respostasToken.filter((r) => r.status === 200);
const semNoStore = ok200.filter((r) => !/no-store/i.test(r.cacheControl ?? ""));
record(
"token",
"resposta do token do canal chega com cache-control: no-store",
ok200.length > 0 && semNoStore.length === 0,
ok200.length === 0
? `nenhuma resposta 200 de /auth/realtime-token observada (${respostasToken.length} resposta(s) no total: ${respostasToken.map((r) => r.status).join(", ") || "nenhuma"})`
: semNoStore.length === 0
? `${ok200.length} resposta(s) 200, todas com "${ok200[0]!.cacheControl}"`
: `${semNoStore.length} de ${ok200.length} resposta(s) 200 SEM no-store — cache-control recebido: "${semNoStore[0]!.cacheControl}"`,
);
// ---- cenário 10: atividade com ator agente e reason visível ---------------
// A superfície é a timeline do contato. Enquanto o bloco 2.2 não entrega o
// ator/reason na tela, isto FALHA e diz o que falta.
// A timeline é do CONTATO, então o cenário 10 precisa de um lead que tenha um.
// Movo o card desse lead e verifico se a atividade nasce com ator e motivo.
const alvo10 = await (async () => {
const r = await admin
.from("crm_leads")
.select("id,title,contact_id")
.eq("pipeline_id", CREDS.crm_vivo.pipeline_id)
.not("contact_id", "is", null)
.limit(5);
if (r.error) throw new Error(`leads com contato: ${r.error.message}`);
return ((r.data ?? []) as { title: string; contact_id: string }[])[0] ?? null;
})();
if (!alvo10) {
record("10", "atividade de mudança de estágio na timeline", false, "nenhum lead do board tem contato — a timeline do contato não teria como mostrar");
} else {
const tituloCurto = alvo10.title.slice(0, 28);
const moveu = await moverPorTeclado(abaA, tituloCurto);
record(
"10.a",
"a AÇÃO aconteceu (card com contato mudou de estágio)",
moveu,
moveu ? `"${tituloCurto}" mudou de coluna` : `"${tituloCurto}" não saiu do lugar em nenhuma direção`,
);
await abaA.goto(`${BASE}/app/contacts/${alvo10.contact_id}`);
await abaA.waitForLoadState("networkidle").catch(() => null);
// A página do contato abre em "Visão geral": a timeline é OUTRA aba. Ler sem
// clicar mede a tela errada — e eu ia acusar o emissor por isso.
const abaTimeline = abaA.getByRole("tab", { name: /timeline/i }).or(
abaA.getByRole("button", { name: /^Timeline$/i }),
).first();
if ((await abaTimeline.count()) > 0) {
await abaTimeline.click();
}
// Esperar o PAINEL ter conteúdo, não um tempo fixo. A timeline busca depois
// do clique; ler cedo devolve painel vazio e eu acusaria a tela de não
// renderizar o que ela só ainda não tinha buscado.
await abaA
.locator('[role="tabpanel"]:visible')
.filter({ hasText: /./ })
.first()
.waitFor({ state: "visible", timeout: 30_000 })
.catch(() => null);
await abaA.waitForFunction(
() => {
const p = Array.from(document.querySelectorAll('[role="tabpanel"]')).find(
(x) => (x as HTMLElement).offsetParent !== null,
);
return !!p && (p.textContent ?? "").trim().length > 0;
},
undefined,
{ timeout: 30_000 },
).catch(() => null);
const sup = await avaliarSuperficie(abaA, '[role="tabpanel"]');
// PRÉ-CONDIÇÃO da superfície: sem conteúdo, os quatro critérios abaixo são
// INCONCLUSIVOS — "não tem uuid" e "não tem rótulo cru" seriam verdes por
// ausência, não por comportamento.
const superficieTemConteudo = sup.texto.trim().length > 0;
record(
"10.pre",
"a superfície da timeline tem conteúdo para avaliar",
superficieTemConteudo,
superficieTemConteudo
? `${sup.texto.length} caracteres no painel visível`
: "painel vazio — os critérios seguintes seriam verdes por ausência, não por acerto",
);
record(
"10.a-texto",
"TimelineView: o motivo humano aparece literalmente",
sup.temMotivoHumano,
sup.temMotivoHumano
? "texto 'Movido de X para Y' presente na linha"
: `motivo humano AUSENTE — a tela mostra: "${sup.texto.slice(0, 110)}"`,
);
record(
"10.b-uuid",
"TimelineView: NENHUM uuid visível para o usuário",
superficieTemConteudo && sup.uuidsVisiveis.length === 0,
sup.uuidsVisiveis.length === 0
? "nenhum identificador cru na tela"
: `${sup.uuidsVisiveis.length} uuid(s) no rosto do usuário, ex.: ${sup.uuidsVisiveis[0]}`,
);
record(
"10.c-ator",
"TimelineView: o ator é distinguível (disco preenchido = humano, anel = agente)",
sup.atorDistinguivel,
sup.atorDistinguivel ? "marca de ator presente" : "nenhuma marca geométrica de ator na timeline",
);
record(
"10.d-rotulo",
"TimelineView: rótulo em pt-BR, não o identificador cru",
superficieTemConteudo && !sup.temRotuloCru,
sup.temRotuloCru ? "tipo cru visível (ex.: stage_changed) em vez de rótulo humano" : "rótulos humanos",
);
await shotPage(abaA, "wave-3-c10-timeline-contato.png", false);
}
// ---- SEGUNDA SUPERFÍCIE: o painel lateral do inbox ------------------------
// A mesma tabela é lida por duas telas. Provar numa só deixaria a outra livre
// para divergir — e foi exatamente assim que o vocabulário divergiu antes.
const conversaId = (CREDS.lgpd as { conversation_id?: string } | undefined)?.conversation_id;
if (!conversaId) {
record("10-inbox", "painel do inbox: superfície avaliada", false, "sem conversa de referência no .e2e-creds.json");
} else {
await abaA.goto(`${BASE}/app/inbox`);
await abaA.waitForLoadState("networkidle").catch(() => null);
// CLICAR na conversa, como o usuário. Passar o id na URL não seleciona nada:
// o painel fica no texto de convite e eu mediria a tela de espera.
const itemConversa = abaA
.getByRole("button", { name: /Ana Souza/i })
.or(abaA.getByText(/Ana Souza LGPD E2E/i))
.first();
if ((await itemConversa.count()) > 0) {
await itemConversa.click().catch(() => null);
await abaA.waitForTimeout(2500);
}
const painel = await avaliarSuperficie(abaA, "aside");
// "Tem conteúdo" NÃO basta: o texto de convite ("Selecione uma conversa…")
// é conteúdo e faria os critérios negativos passarem por ausência de dados.
// A pré-condição exige a superfície REAL, não a tela de espera.
const ehPlaceholder = /Selecione uma conversa/i.test(painel.texto);
const temConteudo = painel.texto.trim().length > 0 && !ehPlaceholder;
record(
"10-inbox.pre",
"painel do inbox mostra a superfície real (não o texto de convite)",
temConteudo,
ehPlaceholder
? 'painel exibe "Selecione uma conversa…" — a conversa não foi selecionada; os critérios abaixo seriam verdes por ausência'
: `${painel.texto.length} caracteres de conteúdo real`,
);
record(
"10-inbox.texto",
"painel do inbox: motivo humano literal",
temConteudo && painel.temMotivoHumano,
painel.temMotivoHumano
? "texto 'Movido de X para Y' presente"
: `motivo humano ausente — painel diz: "${painel.texto.slice(0, 110)}"`,
);
record(
"10-inbox.uuid",
"painel do inbox: nenhum uuid visível",
temConteudo && painel.uuidsVisiveis.length === 0,
painel.uuidsVisiveis.length === 0 ? "sem identificador cru" : `uuid na tela: ${painel.uuidsVisiveis[0]}`,
);
record(
"10-inbox.rotulo",
"painel do inbox: rótulo em pt-BR",
temConteudo && !painel.temRotuloCru,
painel.temRotuloCru ? "tipo cru visível" : "rótulos humanos",
);
await shotPage(abaA, "wave-3-c10-painel-inbox.png", false);
// LADO B do painel: quando a LEITURA FALHA, a tela tem de dizer que falhou —
// nunca "Sem leads". Erro convertido em lista vazia transforma falha técnica
// em afirmação sobre o negócio, e o usuário acredita.
// Intercepto os dois caminhos possíveis: consulta direta ao banco (hoje) e
// rota de servidor (depois da migração), para o gate sobreviver à mudança.
await abaA.route(/\/rest\/v1\/crm_leads|\/api\/v1\/.*(leads|crm)/, (rota) =>
rota.fulfill({ status: 500, contentType: "application/json", body: '{"message":"falha simulada"}' }),
);
await abaA.reload();
await abaA.waitForLoadState("networkidle").catch(() => null);
const itemDeNovo = abaA.getByText(/Ana Souza LGPD E2E/i).first();
if ((await itemDeNovo.count()) > 0) {
await itemDeNovo.click().catch(() => null);
await abaA.waitForTimeout(3000);
}
const comFalha = ((await abaA.locator("aside").last().innerText()) ?? "").replace(/\s+/g, " ");
const mentiu = /Sem leads|Sem atividade/i.test(comFalha);
const avisou = /falha|erro|não foi possível|nao foi possivel|tentar de novo/i.test(comFalha);
record(
"10-inbox.falha",
"leitura que FALHA aparece como falha, não como 'Sem leads'",
avisou && !mentiu,
mentiu
? 'com a leitura falhando, o painel afirma "Sem leads/Sem atividade" — erro disfarçado de fato sobre o negócio'
: avisou
? "painel sinaliza falha de leitura"
: `painel não diz nada: "${comFalha.slice(0, 100)}"`,
);
await shotPage(abaA, "wave-3-c10-painel-falha-de-leitura.png", false);
await abaA.unroute(/\/rest\/v1\/crm_leads|\/api\/v1\/.*(leads|crm)/).catch(() => null);
}
// ---- cenário 11: a decisão de NÃO enviar aparece --------------------------
// Ancorado num VETO REAL já registrado na org, não num caso sintético: hoje há
// 21 decisões de não-enviar em `before_send_traces` (16 por promessa semântica,
// 3 por limite de aquecimento, 1 por contato bloqueado, 1 fora da janela) — e
// NENHUMA delas é visível para um humano. É o cenário 11 em números.
const veto = await (async () => {
const r = await admin
.from("before_send_traces")
.select("contact_id,vetoed_gate,vetoed_code,created_at")
.eq("organization_id", CREDS.org_id)
.not("vetoed_gate", "is", null)
.order("created_at", { ascending: false })
.limit(50);
if (r.error) throw new Error(`vetos: ${r.error.message}`);
const linhas = (r.data ?? []) as { contact_id: string; vetoed_gate: string; vetoed_code: string }[];
const comLead = await admin
.from("crm_leads")
.select("contact_id")
.in("contact_id", linhas.map((x) => x.contact_id));
const ids = new Set(((comLead.data ?? []) as { contact_id: string }[]).map((x) => x.contact_id));
return linhas.find((x) => ids.has(x.contact_id)) ?? null;
})();
if (!veto) {
record("11", "decisão de NÃO enviar aparece na timeline", false, "nenhum veto com lead na org — sem caso real para ancorar");
} else {
await abaA.goto(`${BASE}/app/contacts/${veto.contact_id}`);
await abaA.waitForLoadState("networkidle").catch(() => null);
await abaA.waitForTimeout(4000);
const texto = ((await abaA.locator("main").first().textContent()) ?? "").replace(/\s+/g, " ");
// Aceita o código cru OU um rótulo humano do mesmo motivo — o que importa é
// que a decisão de silêncio esteja NA TELA, não a forma exata da palavra.
const rotulos = [veto.vetoed_code, veto.vetoed_gate, "não envi", "nao envi", "veto", "bloque", "promessa"];
const achou = rotulos.some((r) => new RegExp(r, "i").test(texto));
record(
"11",
"decisão de NÃO enviar aparece na timeline",
achou,
achou
? `veto "${veto.vetoed_gate}/${veto.vetoed_code}" visível na timeline do contato`
: `veto REAL existe no banco ("${veto.vetoed_gate}/${veto.vetoed_code}") e NÃO aparece na tela do contato — o silêncio do agente segue invisível. Pendente do bloco 2.3.`,
);
await shotPage(abaA, "wave-3-c11-timeline-veto.png", false);
}
await browser.close();
const verdes = resultados.filter((x) => x.estado === "PASS");
const falhas = resultados.filter((x) => x.estado === "FALHA");
const bloqueados = resultados.filter((x) => x.estado === "BLOQUEADO");
console.info(
`\n=== WAVE 3: ${verdes.length} verdes · ${falhas.length} vermelhos · ${bloqueados.length} bloqueado(s) ===`,
);
for (const f of falhas) console.info(` FALHA [${f.n}] ${f.nome}: ${f.detalhe}`);
for (const b of bloqueados) console.info(` BLOQUEADO [${b.n}] ${b.nome}: ${b.detalhe}`);
if (falhas.length) process.exit(1);
}
main().catch((err) => {
console.error("❌ Cenários 10-12 falharam:", err);
process.exit(1);
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Wave 3 — cenário 9 da §7: realtime com DUAS ABAS, sem F5. Mais o negativo de
* isolamento entre tenants.
*
* Desenho (aprovado pelo regente):
* ABA A — manager da org A, move o card de estágio pelo TECLADO (o dnd mantém
* o handle mesmo com role="group": espaço levanta, seta move, espaço solta).
* É interação de usuário real e não sofre a fragilidade do arrasto de mouse.
* ABA B — outro BrowserContext, sessão independente, mesma org. Tem que ver o
* card mudar de coluna SEM RELOAD.
* ABA C — usuário de OUTRA ORG. NÃO pode receber nada. Sem este, o teste prova
* entrega e não prova isolamento — e realtime que vaza entre tenants é
* pior que realtime que não funciona.
*
* DIAGNÓSTICO SEPARADO — o detalhe que faz o relatório valer:
* "não apareceu" → o evento nunca saiu. Investigar publicação/trigger.
* "só apareceu após reload" → o evento saiu e o cliente não escutou. Investigar
* o hook e o filtro do canal.
* Juntar os dois manda o dev para o lugar errado metade das vezes.
*
* Run: E2E_PORT=3020 npx tsx tests/capture-wave-3-realtime.ts
*/
import { chromium, type BrowserContext, type Page } from "@playwright/test";
import * as fs from "node:fs";
import { BASE, CARD_ATTR, CREDS, EVIDENCE, gotoBoard, login, shotPage } from "./qa-helpers";
const ALVO = "Marina Costa — clareamento";
const ESPERA_REALTIME_MS = 15_000;
const resultados: { nome: string; ok: boolean; detalhe: string }[] = [];
function record(nome: string, ok: boolean, detalhe: string): void {
resultados.push({ nome, ok, detalhe });
console.info(`${ok ? "PASS" : "FALHA"} ${nome} — ${detalhe}`);
}
/** Em que coluna o card está, lido do DOM (índice da coluna droppable). */
async function colunaDoCard(page: Page, titulo: string): Promise<string | null> {
return page.evaluate(
({ attr, alvo }: { attr: string; alvo: string }) => {
for (const card of Array.from(document.querySelectorAll(`[${attr}]`))) {
if (!(card.textContent ?? "").includes(alvo)) continue;
const coluna = card.closest("[data-rfd-droppable-id]");
const titulo = coluna?.parentElement?.querySelector("h2, h3, header")?.textContent;
return (titulo ?? coluna?.getAttribute("data-rfd-droppable-id") ?? "?").trim();
}
return null;
},
{ attr: CARD_ATTR, alvo: titulo },
);
}
/** Move o card para a coluna seguinte pelo teclado — como faria quem não usa mouse. */
async function moverPorTeclado(page: Page, titulo: string): Promise<void> {
const card = page.locator(`[${CARD_ATTR}]`).filter({ hasText: titulo }).first();
await card.focus();
await page.keyboard.press("Space"); // levanta
await page.waitForTimeout(400);
await page.keyboard.press("ArrowRight"); // move para a coluna seguinte
await page.waitForTimeout(400);
await page.keyboard.press("Space"); // solta
await page.waitForTimeout(1200);
}
/**
* Espera a mudança chegar SEM reload. Se estourar, recarrega e olha de novo —
* é o que separa "evento nunca saiu" de "cliente não escutou".
*/
async function esperarSemReload(
page: Page,
titulo: string,
colunaAntiga: string,
): Promise<{ chegou: boolean; apenasAposReload: boolean; colunaFinal: string | null }> {
const limite = Date.now() + ESPERA_REALTIME_MS;
while (Date.now() < limite) {
const atual = await colunaDoCard(page, titulo);
if (atual && atual !== colunaAntiga) {
return { chegou: true, apenasAposReload: false, colunaFinal: atual };
}
await page.waitForTimeout(500);
}
await page.reload();
await page
.locator(`[${CARD_ATTR}]`)
.first()
.waitFor({ state: "visible", timeout: 20_000 })
.catch(() => null);
const depois = await colunaDoCard(page, titulo);
return {
chegou: false,
apenasAposReload: !!depois && depois !== colunaAntiga,
colunaFinal: depois,
};
}
async function abrirTenantB(ctx: BrowserContext): Promise<Page> {
const page = await ctx.newPage();
const b = CREDS.tenant_b;
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(b.email);
await page.locator("#password").fill(b.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app\//, { timeout: 20_000 });
await page.goto(`${BASE}/app/pipelines/${b.pipeline_id}`);
await page.waitForLoadState("networkidle").catch(() => null);
console.info(`[aba C] tenant B logado (${b.email}) → ${page.url()}`);
return page;
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
if (!CREDS.tenant_b) {
throw new Error("falta o bloco tenant_b — rode: npx tsx scripts/seed-e2e-tenant-b.ts");
}
const browser = await chromium.launch();
// Contextos INDEPENDENTES: duas páginas do mesmo contexto compartilham storage
// e provariam estado compartilhado no cliente, não entrega pelo servidor.
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const ctxC = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const abaA = await ctxA.newPage();
const abaB = await ctxB.newPage();
await login(abaA, "manager");
await gotoBoard(abaA);
await login(abaB, "manager");
await gotoBoard(abaB);
const abaC = await abrirTenantB(ctxC);
const colunaAntesA = await colunaDoCard(abaA, ALVO);
const colunaAntesB = await colunaDoCard(abaB, ALVO);
const vazouAntes = (await abaC.locator(`[${CARD_ATTR}]`).allTextContents()).some((t) =>
t.includes(ALVO),
);
console.info(`[estado] A="${colunaAntesA}" B="${colunaAntesB}" C vê o lead da org A? ${vazouAntes}`);
record(
"isolamento inicial: aba C não enxerga lead de outra org",
!vazouAntes,
vazouAntes ? "VAZOU — o board do tenant B mostra lead da org A" : "board do tenant B só com dados dele",
);
await shotPage(abaB, "wave-3-aba-b-antes.png", false);
console.info(`[ação] aba A move "${ALVO}" pelo teclado…`);
await moverPorTeclado(abaA, ALVO);
const colunaDepoisA = await colunaDoCard(abaA, ALVO);
record(
"aba A: card mudou de coluna",
!!colunaDepoisA && colunaDepoisA !== colunaAntesA,
`"${colunaAntesA}" → "${colunaDepoisA}"`,
);
const r = await esperarSemReload(abaB, ALVO, colunaAntesB ?? "");
record(
"aba B: mudança aparece SEM reload",
r.chegou,
r.chegou
? `chegou em "${r.colunaFinal}" sem F5`
: r.apenasAposReload
? `SÓ APARECEU APÓS RELOAD — o evento saiu, o cliente não escutou. Investigar o hook e o filtro do canal (não a publicação).`
: `NÃO APARECEU nem após reload (coluna final "${r.colunaFinal}") — o evento nunca saiu. Investigar publicação e trigger.`,
);
await shotPage(abaB, "wave-3-aba-b-depois.png", false);
const vazouDepois = (await abaC.locator(`[${CARD_ATTR}]`).allTextContents()).some((t) =>
t.includes(ALVO),
);
record(
"isolamento: aba C NÃO recebeu o evento da org A",
!vazouDepois,
vazouDepois ? "VAZOU entre tenants" : "nada do tenant A chegou no tenant B",
);
await shotPage(abaC, "wave-3-aba-c-outro-tenant.png", false);
await browser.close();
const falhas = resultados.filter((x) => !x.ok);
console.info(`\n=== WAVE 3 / cenário 9: ${resultados.length - falhas.length}/${resultados.length} PASS ===`);
for (const f of falhas) console.info(` FALHA ${f.nome}: ${f.detalhe}`);
if (falhas.length) process.exit(1);
}
main().catch((err) => {
console.error("❌ Realtime (cenário 9) falhou:", err);
process.exit(1);
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Cenário 8 da §7 — o antes/depois do board, lado a lado numa imagem só.
*
* Compõe `wave-0-board-antes.png` e `wave-2-board-depois.png` numa página HTML
* e fotografa o resultado. Sem dependência de imagem: o navegador que já está
* no projeto é o compositor.
*
* Run: npx tsx tests/compose-antes-depois.ts
*/
import { chromium } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
import { EVIDENCE } from "./qa-helpers";
const ANTES = path.join(EVIDENCE, "wave-0-board-antes.png");
const DEPOIS = path.join(EVIDENCE, "wave-2-board-depois.png");
const SAIDA = path.join(EVIDENCE, "wave-2-antes-depois.png");
function dataUri(file: string): string {
if (!fs.existsSync(file)) throw new Error(`[cenário 8] falta a captura: ${file}`);
return `data:image/png;base64,${fs.readFileSync(file).toString("base64")}`;
}
async function main(): Promise<void> {
const html = `<!doctype html>
<meta charset="utf-8">
<style>
body { margin: 0; background: #f6f5f2; font: 14px/1.4 system-ui, sans-serif; color: #2b2a27; }
.par { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; }
figure { margin: 0; }
figcaption { font-weight: 600; margin-bottom: 8px; }
figcaption span { font-weight: 400; color: #6b6a66; }
img { width: 100%; border: 1px solid #d8d6d0; border-radius: 6px; display: block; }
</style>
<div class="par">
<figure>
<figcaption>ANTES — Wave 0 <span>· 5 alturas distintas, 116→154px (38px de variação) · tags no card</span></figcaption>
<img src="${dataUri(ANTES)}">
</figure>
<figure>
<figcaption>DEPOIS — Wave 2 <span>· altura constante 144px em 12 cards · tags fora · quem esfriou aparece</span></figcaption>
<img src="${dataUri(DEPOIS)}">
</figure>
</div>`;
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 2400, height: 1000 } })
).newPage();
await page.setContent(html);
await page.waitForLoadState("networkidle").catch(() => null);
await page.screenshot({ path: SAIDA, fullPage: true });
await browser.close();
console.info(`[evidencia] evidence/${path.basename(SAIDA)} — antes/depois lado a lado`);
}
main().catch((err) => {
console.error("❌ Composição antes/depois falhou:", err);
process.exit(1);
});
+178
View File
@@ -0,0 +1,178 @@
/**
* PROVA: o canal de realtime de `useAgentRuns` está morto — assina como anônimo
* e nunca recebe evento.
*
* A lista de canais suspeitos do regente é inferência de código (grep). Isto a
* torna FATO por observação, do mesmo jeito que provamos o canal do board:
* 1. abre a tela com sessão real (gerente logado);
* 2. lê o token que o socket usa — é `anon` ou é o usuário?
* 3. provoca uma mudança REAL na tabela vigiada, com o filtro do canal;
* 4. mede se algum quadro chega e se a tela muda SEM recarregar;
* 5. devolve o dado ao estado anterior.
*
* Não conserta nada. Só prova.
*
* Run: E2E_PORT=3020 npx tsx tests/prova-canal-agent-runs.ts
*/
import { chromium } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import { BASE, CREDS, EVIDENCE, login, shotPage } from "./qa-helpers";
const envFile = fs.readFileSync(".env.local", "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
/**
* ATENÇÃO DE MÉTODO: o parâmetro `apikey` da URL do socket é SEMPRE a chave
* pública — isso é do desenho do Supabase e NÃO prova anonimato. Quem carrega a
* identidade do usuário é o `access_token` dentro do quadro de assinatura
* (ou um setAuth antes dele). Portanto a pergunta certa é: o join do canal leva
* access_token de usuário, ou vai sem identidade?
*/
/** Lê o papel do JWT sem validar assinatura — só para dizer QUEM o socket diz ser. */
function papelDoToken(jwt: string | undefined): string {
if (!jwt) return "(sem token)";
try {
const corpo = JSON.parse(
Buffer.from(jwt.replace(/^Bearer /, "").split(".")[1]!, "base64").toString(),
);
return `role=${corpo.role}${corpo.sub ? ` sub=${String(corpo.sub).slice(0, 8)}` : " sub=(nenhum)"}`;
} catch {
return "(jwt ilegível)";
}
}
async function main(): Promise<void> {
fs.mkdirSync(EVIDENCE, { recursive: true });
const runsRes = await admin
.from("ai_agent_runs")
.select("id,agent_id,latency_ms")
.eq("organization_id", CREDS.org_id)
.order("created_at", { ascending: false })
.limit(1);
if (runsRes.error) throw new Error(`runs: ${runsRes.error.message}`);
const run = (runsRes.data ?? [])[0] as { id: string; agent_id: string; latency_ms: number | null };
if (!run) throw new Error("nenhuma execução de agente na org — sem caso para provar");
const latenciaOriginal = run.latency_ms;
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
page.setDefaultTimeout(90_000);
page.setDefaultNavigationTimeout(90_000);
const quadros: string[] = [];
let tokenDoSocket = "(socket não abriu)";
let entrouNoCanal = false;
let joinDoCanal = "";
const joins: { canal: string; temToken: boolean }[] = [];
page.on("websocket", (ws) => {
if (!ws.url().includes("supabase")) return;
const url = new URL(ws.url());
tokenDoSocket = papelDoToken(url.searchParams.get("apikey") ?? undefined);
ws.on("framesent", (f) => {
const s = String(f.payload);
if (s.includes("ai-agent-runs")) {
entrouNoCanal = true;
// O quadro INTEIRO do canal sob teste: é nele que se vê se há identidade.
joinDoCanal = s;
}
if (/phx_join/.test(s)) {
// A/B na MESMA página: canal curado (passa pelo hook, com setAuth) contra
// canal cru (abre .channel direto). A digital é o access_token no join.
const nome = (s.match(/realtime:([^"]+)/) ?? [])[1] ?? "?";
joins.push({ canal: nome.slice(0, 46), temToken: /access_token/.test(s) });
}
if (/phx_join|access_token/.test(s)) quadros.push(`ENVIOU ${s.slice(0, 220)}`);
});
ws.on("framereceived", (f) => {
const s = String(f.payload);
if (/postgres_changes|phx_reply|error/.test(s)) quadros.push(`RECEBEU ${s.slice(0, 220)}`);
});
});
await login(page, "manager");
await page.goto(`${BASE}/app/ai/agents/${run.agent_id}`);
await page.waitForLoadState("networkidle").catch(() => null);
// O canal só liga quando a aba de execuções abre — sem clicar, eu mediria uma
// tela que nunca assinou e concluiria "canal morto" sobre um canal ausente.
const abaRuns = page.getByRole("tab", { name: /runs|execu/i }).first();
if ((await abaRuns.count()) > 0) {
await abaRuns.click();
console.info("[nav] abriu a aba de execuções");
} else {
console.info("[nav] ATENÇÃO: aba de execuções não encontrada — o canal pode nem ligar");
}
await page.waitForTimeout(6000);
await shotPage(page, "prova-canal-agent-runs-antes.png", false);
const textoAntes = ((await page.locator("main").first().innerText()) ?? "").replace(/\s+/g, " ");
const marcaAntes = quadros.length;
// GATILHO: mudança real na tabela vigiada, dentro do filtro do canal.
const novaLatencia = (latenciaOriginal ?? 0) + 4242;
const up = await admin
.from("ai_agent_runs")
.update({ latency_ms: novaLatencia } as never)
.eq("id", run.id);
if (up.error) throw new Error(`gatilho: ${up.error.message}`);
console.info(`[gatilho] latency_ms ${latenciaOriginal} -> ${novaLatencia} no run ${run.id.slice(0, 8)}`);
await page.waitForTimeout(12_000);
const textoDepois = ((await page.locator("main").first().innerText()) ?? "").replace(/\s+/g, " ");
const quadrosNovos = quadros.length - marcaAntes;
const telaMudou = textoAntes !== textoDepois;
// Restaura o dado — a prova não pode deixar rastro.
const back = await admin
.from("ai_agent_runs")
.update({ latency_ms: latenciaOriginal } as never)
.eq("id", run.id);
if (back.error) throw new Error(`restaurar: ${back.error.message}`);
await shotPage(page, "prova-canal-agent-runs-depois.png", false);
await browser.close();
console.info("\n=== RESULTADO ===");
console.info(` socket abriu com: ${tokenDoSocket}`);
console.info(` entrou no canal ai-agent-runs? ${entrouNoCanal ? "SIM" : "NÃO"}`);
if (joinDoCanal) {
const temToken = /access_token/.test(joinDoCanal);
console.info(` o join do canal leva access_token de usuário? ${temToken ? "SIM" : "NÃO — vai sem identidade"}`);
console.info(` quadro do join: ${joinDoCanal.slice(0, 300)}`);
}
console.info("\n A/B dos canais nesta MESMA página (identidade no join):");
for (const j of joins) {
console.info(` ${j.temToken ? "COM token " : "SEM token "} ${j.canal}`);
}
console.info(`\n quadros novos após o gatilho: ${quadrosNovos}`);
console.info(` a tela mudou sem recarregar? ${telaMudou ? "SIM" : "NÃO"}`);
console.info(` [restaurado] latency_ms de volta para ${latenciaOriginal}`);
if (quadros.length) {
console.info("\n amostra de quadros:");
for (const q of quadros.slice(0, 4)) console.info(` ${q.slice(0, 180)}`);
}
const canalMorto = entrouNoCanal && quadrosNovos === 0 && !telaMudou;
console.info(
canalMorto
? "\n==> CANAL MORTO CONFIRMADO: assina, recebe ok, e não entrega evento."
: "\n==> NÃO confirmado como morto — ver números acima antes de concluir.",
);
}
main().catch((err) => {
console.error("❌ Prova do canal falhou:", err);
process.exit(1);
});
+23 -3
View File
@@ -26,7 +26,17 @@ import * as path from "node:path";
import { describe, expect, it } from "vitest";
const RAIZ = process.cwd();
const DOCS = ["HANDOFF-crm-vivo.md", "BRIEFING-crm-vivo.md", "HANDOFF-lgpd.md"];
const DOCS = [
"HANDOFF-crm-vivo.md",
"BRIEFING-crm-vivo.md",
"HANDOFF-lgpd.md",
// As narrativas por wave também citam prova, e citação morta é citação morta
// em qualquer documento — não só nos do regente.
"evidence/wave-0.md",
"evidence/wave-1.md",
"evidence/wave-2.md",
"evidence/lgpd.md",
];
/** Tudo que o git ENTREGA — não o que o disco tem. */
function versionados(): Set<string> {
@@ -34,10 +44,20 @@ function versionados(): Set<string> {
return new Set(saida.split("\n").filter(Boolean));
}
/** Referências a imagens de evidência dentro dos documentos da entrega. */
/**
* Referências a imagens de evidência, NORMALIZADAS para o caminho do repositório.
*
* Casar só `evidence/*.png` deixava passar os documentos que vivem DENTRO de
* `evidence/` — eles citam por nome puro (`wave-1-card.png`), e o check passava
* vacuamente justamente nos quatro arquivos que eu tinha acabado de incluir.
* Verde vácuo dentro do próprio guarda escrito contra ele.
*/
function citadas(doc: string): string[] {
const texto = fs.readFileSync(path.join(RAIZ, doc), "utf8");
const achados = texto.match(/evidence\/[\w./-]+\.png/g) ?? [];
const dir = path.dirname(doc);
const achados = (texto.match(/[\w./-]*[\w-]+\.png/g) ?? []).map((ref) =>
ref.startsWith("evidence/") ? ref : path.posix.join(dir === "." ? "evidence" : dir, path.basename(ref)),
);
return [...new Set(achados)];
}