Files
DeskcommCRM/tests/sonda-inbox-cabe-na-tela.ts
jmpoandClaude Opus 5 f691b9c8f8 merge: 198 commits da main — duas convergências e um desfecho a corrigir
O fork estava 198 commits atrás. A `main` já tinha absorvido o PR #200
(canal Zernio) e renumerado as migrations que vieram daqui, então boa
parte do conflito era o MESMO trabalho com dois números.

─── O que era duplicata ───

`0117_canal_zernio_vocabulario` e `0118_zernio_envio` diferem de `0131` e
`0132` em UMA linha: o número no comentário do cabeçalho. São as minhas,
renumeradas lá. Ficam as de lá — numeração é verdade compartilhada, e
manter as duas deixaria o mesmo schema com dois nomes.

`0119_contato_lookup_telefone` e `0122_telefone_do_lid` PARECIAM a mesma
convergência e não são: uma acrescenta `phone_lookup_at` (fila do cron de
backfill), a outra `wa_lid` (coluna gerada + dedup). Convivem.

─── O defeito que o merge criaria, e que ninguém veria ───

A `0139_kind_check_completo` reconstrói `agent_inbox_items_kind_check`, e
a minha `0120` também. Pelo timestamp a minha roda DEPOIS — e a lista
dela não conhecia `midia_nao_lida` nem `contact_proposal_expired`.
Aplicar as duas em ordem, num clone atualizado, APAGARIA dois kinds em
uso: quem reconstrói por último é quem manda, e uma lista incompleta não
é "aditiva", é uma remoção silenciosa. A `0120` passou a trazer a lista
inteira.

No `baseline.sql` continua UM bloco só para essa constraint, agora com os
kinds dos dois lados — a regra do #159, que o `update.sh` de todo clone
depende.

─── Onde discordei da main, e por quê ───

`zernio.ts` — `isConfigured()` fica `true`, e não
`zernioCredsFromEnv() !== null`.

A preocupação de lá é real: `isConfigured() === true` não pode implicar
"sem credencial", porque o handler grava `sent` quando `send()` não
lança. Só que quem fecha esse par mudou de lugar — `send()` LANÇA
`zernio_not_configured`, e o caso descrito naquele comentário ("devolve
`{externalId: null}` SEM lançar") não existe mais no arquivo.

Exigir env reintroduziria um defeito medido: `resolveZernioCreds` procura
primeiro na SESSÃO e só depois no ambiente. Uma instalação que conectou
pelo botão tem a credencial no banco e nada no `.env` — a checagem por
env diria "não configurado" para um canal conectado e funcionando, e toda
mensagem ficaria em `queued` sem tentar sair.

Mas o teste de lá estava certo no DESFECHO: sem credencial nenhuma, o
certo é `queued` com o motivo, não `failed`. Isso não estava atendido de
nenhum dos dois lados — o `catch` do handler marcava `failed`. Agora
`notConfigured` é traduzido para `queued`, ao lado do
`storage_sign_failed` que já tinha tratamento próprio: falta de
credencial não é falha DESTA mensagem, é canal ainda não conectado, e
marcar `failed` faria o follow-up desistir de algo que sai sozinho assim
que alguém conectar.

Os dez casos de `messages-handler-canal-intermediado` passam.

─── O que fica vermelho, e não é daqui ───

`lib/ai/dispatcher/rate-limit.test.ts` — 5 casos estourando o timeout de
15s. Falha também com os arquivos da main trocados um a um, então não
vem desta resolução; o CI de lá está verde, então é diferença de
ambiente. Fica registrado, não escondido.

3647 casos passam. typecheck, lint:channels e build limpos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:57:33 -03:00

179 lines
7.9 KiB
TypeScript

/**
* O INBOX CABE NA TELA — regressão de layout, medida por ferramenta.
*
* ## O defeito que esta sonda existe para não deixar voltar
*
* O `xl` do Tailwind dispara em 1280px, e era ali que a terceira coluna do
* inbox nascia — no ponto exato em que não havia espaço para ela. Medido: o
* painel de CRM ficava **311px fora da viewport em 1280px** e 151px em 1440px.
* Alcançável só rolando o `main` de lado, que ninguém faz. Na prática, em
* 1280px o atendente não via contexto nenhum do cliente.
*
* A causa não era o grid: era o `ConversationHeader`, cujo `min-content` era
* **707px** porque a barra de ações era `shrink-0`. `1fr` é
* `minmax(auto, 1fr)` e não encolhe abaixo do conteúdo.
*
* ## Por que sonda de browser e não teste unitário
*
* `min-content`, quebra de flex e resolução de `grid-template-columns` são
* cálculo de layout. O jsdom não tem engine de layout: um teste lá mediria
* zero em tudo e passaria feliz — verde por ausência de motor, o pior falso
* verde que existe. Só um browser real responde a esta pergunta.
*
* A catraca barata que roda no CI é `tests/unit/inbox-header-nao-trava.test.tsx`;
* esta sonda é a medição de verdade.
*
* Uso: `npx tsx tests/sonda-inbox-cabe-na-tela.ts`
* Requer o app na 3100 apontando para o Supabase LOCAL.
*/
import { readFileSync } from "node:fs";
import { chromium } from "@playwright/test";
const BASE = process.env.E2E_PORT ? `http://127.0.0.1:${process.env.E2E_PORT}` : "http://127.0.0.1:3100";
const CONVERSA = "8b9fcd5f-252d-4f6b-9538-f7c2c538807f";
const c = JSON.parse(readFileSync("/Users/rafaelmelgaco/DeskcommCRM/.e2e-creds.json", "utf8"));
/** 1280 e 1366 são as apertadas; 1920 é o controle de que nada regrediu no largo. */
const LARGURAS = [1280, 1366, 1440, 1536, 1920];
/**
* Piso de leitura da conversa. Abaixo disso o thread fica mais estreito que o
* próprio composer (min-content medido: 370px) e a coluna volta a travar. O
* número é a régua, e viaja com o resultado — sem ele, "cabe" não diz se cabe
* com folga ou por um fio.
*/
const THREAD_MIN = 400;
async function main(): Promise<void> {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } });
const p = await ctx.newPage();
const erros: string[] = [];
p.on("console", (m) => {
if (m.type() === "error") erros.push(m.text());
});
await p.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await p.click('input[type="email"]');
await p.locator('input[type="email"]').pressSequentially(c.users.manager.email, { delay: 6 });
await p.click('input[type="password"]');
await p.locator('input[type="password"]').pressSequentially(c.password, { delay: 6 });
await p.click('button[type="submit"]');
await p.waitForURL(/\/app/, { timeout: 30000 });
await p.goto(`${BASE}/app/inbox?id=${CONVERSA}`, { waitUntil: "networkidle" });
await p.waitForTimeout(2500);
const linhas: Array<Record<string, unknown>> = [];
for (const w of LARGURAS) {
await p.setViewportSize({ width: w, height: 900 });
await p.waitForTimeout(600);
linhas.push(
(await p.evaluate(`(() => {
var sec = document.querySelector('[data-testid="inbox-demandas"]');
if (!sec) return { viewport: window.innerWidth, sem_painel: true };
var aside = sec.closest("aside");
var grid = aside.parentElement;
while (grid && getComputedStyle(grid).display !== "grid") grid = grid.parentElement;
var meio = grid.children[1];
var hdr = meio.children[0];
var main = grid.closest("main");
var ar = aside.getBoundingClientRect();
var hr = hdr.getBoundingClientRect();
// Ação perdida = largura zero, ou fora do retângulo do próprio header.
// Reorganizar é aceitável; esconder ação de quem atende, não.
//
// "Ver contato" fica de FORA desta contagem por decisão explícita: a
// partir de xl ele some do header porque o painel lateral, que acabou de
// entrar na tela, tem a mesma porta para o mesmo contato. A verificação
// dele é o caso contato_alcancavel abaixo — que é a pergunta certa
// ("dá para chegar ao contato?"), e não "este botão específico está
// visível aqui".
var acoes = hdr.children[1];
var botoes = Array.prototype.slice
.call(acoes.querySelectorAll("button, a"))
.filter(function (x) { return !/Ver contato/.test(x.innerText || ""); });
// A porta para o contato existe em ALGUM lugar da tela — header ou painel.
var portas = Array.prototype.slice
.call(document.querySelectorAll("a"))
.filter(function (x) {
return /Ver contato/.test(x.innerText || "") && x.getBoundingClientRect().width > 0;
});
return {
viewport: window.innerWidth,
aside_fora: Math.round(Math.max(0, ar.right - window.innerWidth)),
aside_w: Math.round(ar.width),
thread_w: Math.round(meio.getBoundingClientRect().width),
header_h: Math.round(hr.height),
botoes: botoes.length,
botoes_perdidos: botoes.filter(function (x) {
var r = x.getBoundingClientRect();
return r.width < 1 || r.right > hr.right + 1;
}).length,
contato_alcancavel: portas.length > 0,
// Duas portas idênticas na mesma tela é a duplicata que empurrava o
// header para duas linhas em 1280px.
portas_para_o_contato: portas.length,
main_rola_de_lado: main ? main.scrollWidth > main.clientWidth : null,
doc_rola_de_lado:
document.documentElement.scrollWidth > document.documentElement.clientWidth
};
})()`)) as Record<string, unknown>,
);
}
await p.screenshot({ path: "evidence/inbox-cabe-1920.png" });
await p.setViewportSize({ width: 1280, height: 900 });
await p.waitForTimeout(500);
await p.screenshot({ path: "evidence/inbox-cabe-1280.png" });
await b.close();
console.log(
"vp".padStart(6) +
"fora".padStart(7) +
"painel".padStart(8) +
"thread".padStart(8) +
"hdr_h".padStart(7) +
"acoes".padStart(8) +
"portas".padStart(8) +
" rola(main/doc)",
);
for (const r of linhas) {
console.log(
String(r.viewport).padStart(6) +
String(r.aside_fora).padStart(7) +
String(r.aside_w).padStart(8) +
String(r.thread_w).padStart(8) +
String(r.header_h).padStart(7) +
`${r.botoes_perdidos}/${r.botoes}`.padStart(8) +
String(r.portas_para_o_contato).padStart(8) +
` ${r.main_rola_de_lado}/${r.doc_rola_de_lado}`,
);
}
const casos: Array<[string, boolean]> = [
["mediu todas as larguras (guarda de vacuidade)", linhas.length === LARGURAS.length],
["o painel de CRM existe em toda largura xl+", linhas.every((r) => !r.sem_painel)],
["o painel NUNCA fica fora da viewport", linhas.every((r) => r.aside_fora === 0)],
["nenhuma ação do header se perde", linhas.every((r) => r.botoes_perdidos === 0)],
["o header tem ações para perder (vacuidade)", linhas.every((r) => Number(r.botoes) > 0)],
[`a conversa nunca fica abaixo de ${THREAD_MIN}px`, linhas.every((r) => Number(r.thread_w) >= THREAD_MIN)],
["o inbox não rola de lado em largura nenhuma", linhas.every((r) => r.main_rola_de_lado === false)],
["dá para chegar ao contato em toda largura", linhas.every((r) => r.contato_alcancavel === true)],
["e por UMA porta só — sem duplicata na tela", linhas.every((r) => r.portas_para_o_contato === 1)],
["sem erro de console", erros.length === 0],
];
let falhas = 0;
console.log("");
for (const [nome, ok] of casos) {
console.log(`${ok ? " ok " : "FALHA "} ${nome}`);
if (!ok) falhas += 1;
}
console.log(falhas === 0 ? "\nTODOS OS CASOS PASSARAM" : `\n${falhas} CASO(S) FALHARAM`);
process.exit(falhas === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});