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
@@ -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.**
|
||||
|
After Width: | Height: | Size: 145 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -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é.
|
||||
|
After Width: | Height: | Size: 154 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 125 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
|
||||