Runbook — Bate Papo ao vivo do Sagazchat
Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o Bate Papo ao vivo do Sagazchat via Playwright. Ele consolida o conhecimento de UI, seletore
Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o Bate Papo ao vivo do Sagazchat via Playwright. Ele consolida o conhecimento de UI, seletores estáveis, comportamentos disparadores, snippets prontos para cada operação e as lições aprendidas em 2026-05-09. A documentação user-facing (src/content/atendimento/bate-papo-vivo.md) descreve as funções para o usuário humano; este arquivo descreve como acessá-las e executá-las programaticamente sem quebrar nada.
Status de validação: trechos marcados com
[validado]foram executados e confirmados nesta sessão. Trechos com[a validar]são inferidos a partir do mapeamento da UI — confirme antes de usar em produção. Quando a UI mudar, atualize as marcações.
1. Pré-requisitos
- Playwright local (
node_modules/playwright) — usado vianpm installno projeto. Versão validada: 1.59.1. - Chromium do Playwright já instalado em
%USERPROFILE%\AppData\Local\ms-playwright\chromium-1217\(não precisa baixar de novo). - Credenciais de teste: passar via variáveis de ambiente (
SAGAZ_EMAIL,SAGAZ_PASSWORD) na linha de execução. Nunca hardcodar em arquivo. - Avast HTTPS scanning quebra
npm install. Se precisar de pacotes novos, peça ao usuário para desativar HTTPS scanning ou use download direto viaInvoke-WebRequest(URLs do GitHub funcionam sem MITM; URLs de gyan.dev/CDN podem travar no redirect). - FFmpeg do Playwright é build mínimo (
--disable-everything) — só temscale/crop/pad. Sem encoder de GIF. Para gerar GIFs, instalar FFmpeg full em%USERPROFILE%\bin\ffmpeg\(download manual de GitHub release).
2. Sessão persistente (recomendado)
Para evitar relogin a cada execução e manter a conversa aberta entre fases:
scripts/sagaz-session.mjs— abre Chromium em background com--remote-debugging-port=9242, faz login (ou usastate.jsonsalvo), abre a conversa de exemplo e expõe o CDP emscripts/_out/cdp.txt. Mantém vivo atéTaskStop.scripts/_lib/connect.mjs— helper. Lêcdp.txte devolve{ browser, ctx, page }viachromium.connectOverCDP. Cada fase chamaconnect(), faz seu trabalho eawait browser.close()(que apenas desconecta — o Chromium continua aberto).
Importante: use connectOverCDP, não chromium.connect. O segundo isola contextos entre conexões e o browser.contexts()[0] retorna undefined.
Depois do login, o storage state fica salvo em scripts/_out/state.json. Em re-sessões basta carregar o state e ir direto para /live-chat-whatsapp.
3. Mapa da UI por área
Coordenadas e seletores válidos para viewport 1440x900. Em viewport ≥ ~1700 o painel CRM aparece automaticamente à direita; em telas menores ele só abre clicando no nome do contato no header.
3.1 Sidebar (menu lateral, x ≤ 280)
Hierarquia:
- Geral › Dashboard / Bate Papo ao vivo (expansível)
- WhatsApp, WA Oficial, Instagram, Widget Chat, Email, WhatsApp LEGADO
- Criação › Kanban’s, Assistentes IA, …
Não tem <a> nem <nav> — itens são <div role="button">. Use getByText('WhatsApp', { exact: false }) ou regex (text=/^WhatsApp$/).
3.2 Topo da lista (acima das abas)
- Busca
<input placeholder="Encontre conversas">em (~315, 60). - Kebab
⋮“Mais opções” em (592, 32) —aria-label="Mais opções". Menu: Novo atendimento, Chat interno, Agendamento.
3.3 Abas
- “Atendente N”, “Aguardando N”, “Resolvidos” — texto inclui o badge numérico, então use regex:
page.locator('text=/^Atendente/').first(). NuncagetByText('Atendente', { exact: true }).
3.4 Linha de filtros (logo abaixo da busca, y≈114)
Três botões, da esquerda para a direita:
| Pos x | aria | Função |
|---|---|---|
| 311 | (sem) | Filtrar por etiqueta |
| 347 | (sem) | Filtrar por atendente |
| 384 | ”Filtros” | Filtros gerais (Departamentos, WhatsApps, Status) |
Os dois primeiros não têm aria-label — identifique pela ordem e posição.
3.5 Linha da conversa na lista
Cada linha (~62px de altura) é totalmente clicável (cursor:pointer). Ao hover aparece um chevron ▾ à direita (~575, 182) que abre o menu de ações da conversa:
Enviar fluxo, Remarketing, Agendar mensagem,
Transferir contato, Marcar como não lida,
Fechar conversa, Deletar conversa
O ícone de relógio com seta ao lado do timer (“2m”, “1hora”) é decorativo — só indica há quanto tempo foi a última mensagem. Não é botão.
3.6 Header da conversa aberta
O conteúdo do header muda conforme o status do ticket:
Conversa em “Atendente” — 4 botões à direita (todos com aria-label):
| Pos | aria | Função | Risco |
|---|---|---|---|
| 1291,36 | ”Retornar” | Devolve a conversa para Aguardando | Disparador |
| 1330,36 | ”Atalho” | Abre painel de respostas rápidas + fluxos | Seguro |
| 1373,36 | ”Resolver” (fundo verde #17C75A) | Marca como resolvida | Disparador |
| 1417,36 | (sem aria) — kebab ⋮ | Submenu: Agendar mensagem, Transferir contato, Deletar conversa | Seguro abrir |
Conversa em “Aguardando” — 1 botão único:
- “Aceitar Conversa” em (~1360, 36). Composer fica bloqueado (“Reabra ou aceite este ticket para enviar uma mensagem.”).
Conversa em “Resolvidos” — 1 botão único:
- “Reabrir” em (~1391, 36). Reabrir devolve a conversa para a aba Atendente (com o atendente original).
Os ícones em x=1467 e x=1764 que aparecem no scan são do painel CRM lateral, não do header da conversa. Filtre r.left < 1450 para evitar confusão.
3.7 Tags + Etiquetas (faixa abaixo do header)
Há um único <input placeholder="Etiquetas"> em (~880, 103). O label “Tags” ao lado é só texto — clicar em qualquer parte da faixa foca o mesmo input. Ao focar, abre dropdown com as etiquetas cadastradas. Para aplicar uma etiqueta: await input.click() → page.locator('text=Cliente Ativo').click().
3.8 Composer (barra de ações abaixo do campo de mensagem)
7 botões. Identificação confiável pelo SVG path (todos são ícones Lucide):
| # | Pos x | aria | SVG path (início) | Função | Risco |
|---|---|---|---|---|---|
| 0 | 663 | ”Emoji” | M8 14s1.5 2 4 2 4-2 4-2 (smile) | Picker de emoji | Seguro |
| 1 | 711 | (sem) | m16 6-8.414 8.586 (paperclip) | Anexar arquivo (drop zone) | Seguro |
| 2 | 759 | (sem) | m21 17-2.156-1.868 (signature) | Toggle de assinatura (liga/desliga prefixo nome:) | Seguro |
| 3 | 807 | (sem) | (sem paths visíveis no SVG) | Inserir/remover fluxo da conversa | Disparador quando há fluxo |
| 4 | 855 | ”Abrir mensagens rápidas” | M4 14a1 1 0 0 1-.78-1.63 (zap) | Respostas rápidas | Seguro |
| 5 | 1289 | (sem) | M12 14c1.66 0 3-1.34 3-3 (mic) | Gravar áudio — INICIA NA HORA | Disparador |
| 6 | 1366 | (sem) | M13 19V7.83l4.88 4.88 (arrow up) | Enviar — disabled com campo vazio | Seguro |
⚠️ Botão 5 (microfone) inicia gravação imediatamente ao clicar. Se acionar acidentalmente, procure o ícone de lixeira que aparece ao lado durante a gravação para descartar — só recarregue a página em último caso.
3.9 Painel CRM lateral
Em viewport ≥ ~1700 ele aparece automaticamente em x ≥ 1450; em telas menores, abre clicando no nome do contato no header.
Não tem abas — é um único bloco scrollável com seções empilhadas:
- Header “Dados do contato” + botão de edição (lápis ✏️) à direita.
- Identidade — avatar grande, nome, “Criado em DD/MM/AAAA”.
- Telefone, E-mail, Instagram (campos vazios mostram “Não informado”).
- Fluxos — colapsável, expande no chevron.
- Remarketing — botões para cancelar/trocar sequência ativa.
- Observações — contador
0+ botão+. “Sem observações registradas.” quando vazio. - Outras informações — campos personalizados. “Nenhuma informação adicional” quando vazio.
4. Ações seguras vs disparadoras
Princípio: no primeiro pass de auditoria, fazer hover-only (sem click) capturando tooltips e SVG paths. Só clicar em botões com função identificada como segura.
Ações seguras (idempotentes — abrem popovers/pickers):
- Filtros (etiqueta, atendente, gerais)
- Kebab “Mais opções” da lista
- Picker de emoji
- Drop zone de anexo
- Toggle de assinatura
- Respostas rápidas/Atalho
- Chevron da linha da conversa (abre menu)
- Kebab do header da conversa (Atendente)
- Input de Etiquetas
- Click no nome do contato (abre painel CRM)
- Click em aba (Atendente/Aguardando/Resolvidos)
Ações disparadoras (modificam estado real — não clicar sem necessidade):
- Botão Resolver (✓ verde) — fecha a conversa
- Botão Retornar — devolve para Aguardando
- Botão Aceitar Conversa (em Aguardando)
- Botão Gravar áudio — inicia gravação na hora
- Itens do menu da conversa (chevron) que disparam: Enviar fluxo, Remarketing, Agendar mensagem, Transferir contato, Fechar conversa, Deletar conversa
- Itens do kebab do header: Agendar mensagem, Transferir contato, Deletar conversa
- Botão Remover fluxo (composer, ícone 3) — quando há fluxo ativo
5. Quirks e pegadinhas
- Nome do contato é “Fulano Exemplo” com
Imaiúsculo. A fonte do app pode parecerexemplo(l minúsculo). Sempre useFulano Exemploem seletores. - O painel CRM existe no DOM mesmo quando visualmente fechado em viewports estreitas —
document.querySelectorAllretorna seus elementos comx > 1450. Para não confundir com elementos do header, sempre filtre coordenadas. - O label “Tags” ao lado do campo “Etiquetas” não é um campo separado — é só texto decorativo. O foco sempre cai no input “Etiquetas”.
- Texto das abas inclui o badge numérico (“Atendente1”). Use regex:
text=/^Atendente/. - Após
page.reload()oupage.goto(), a conversa selecionada é perdida — reabra antes de continuar a auditoria. page.bringToFront()antes de cliques visuais — Chromium pode ficar atrás de outras janelas.
6. Regras de departamento (para entender o que está na fila)
- A visibilidade no bate-papo é restrita pelos departamentos atribuídos ao atendente.
- Sem fluxo ativo na chegada, a conversa entra sem departamento e fica visível para todos.
- Cliente sem departamento → todos veem; workaround é criar departamento “Geral” e atribuir à chegada.
- Atendente em múltiplos departamentos vê a fila combinada; usa o filtro de Departamentos para focar.
- O filtro “Departamentos” só lista os departamentos a que o atendente tem acesso.
- Transferência muda visibilidade na hora: o departamento antigo perde acesso.
Detalhe completo: src/content/atendimento/bate-papo-vivo.md seção “Abas de atendimento”.
7. Scripts disponíveis em scripts/
| Script | Propósito |
|---|---|
sagaz-session.mjs | Inicia sessão persistente em background |
_lib/connect.mjs | Helper para conectar via CDP |
sagaz-login.mjs | Login standalone (headed, slowMo 350) — útil pra demonstrar visualmente |
sagaz-probe.mjs | Probe da página de login (sem submeter) |
sagaz-audit-1-list-icons.mjs | Audita os 3 filtros acima da lista |
sagaz-audit-2-composer.mjs | Mapeia os 7 ícones do composer (clica todos — risco de gravação) |
sagaz-audit-2b-tooltips.mjs | Hover-only no composer (mais seguro) |
sagaz-audit-2c-isolated.mjs | Click isolado em ícones específicos com reload entre eles |
sagaz-audit-3*-row*.mjs | Linha da conversa + chevron menu |
sagaz-audit-4-conv-header.mjs | Header da conversa em Atendente |
sagaz-audit-5*-tags*.mjs | Tags + Etiquetas |
sagaz-audit-6*-painel*.mjs | Painel CRM lateral |
sagaz-audit-7-list-kebab-and-aguardando.mjs | Kebab da lista + header em Aguardando |
sagaz-stop-recording.mjs | Reload de emergência (matar gravação acidental) |
8. Operações comuns (snippets)
Todos os snippets assumem que você já chamou connect() do helper e tem { browser, ctx, page }. Cada bloco tem:
- Objetivo — o que a operação faz e estado pós-execução.
- Snippet — código Playwright pronto.
- Confirmação — como detectar sucesso visual/programaticamente.
- Status —
[validado]se foi executado nesta sessão;[a validar]caso contrário.
8.1 Abrir uma conversa específica pelo nome
await page.getByText('Fulano Exemplo', { exact: true }).first().click();
await page.waitForTimeout(2500); // header + composer renderizam
Confirmação: header da conversa carrega o nome em < y=50. Status: [validado].
8.2 Trocar de aba (Atendente / Aguardando / Resolvidos)
O texto da aba inclui badge numérico (“Atendente1”). Use regex:
await page.locator('text=/^Aguardando/').first().click();
await page.waitForTimeout(1500);
Status: [validado].
8.3 Aceitar uma conversa em “Aguardando”
Pré-condição: aba “Aguardando” aberta, conversa selecionada. Header mostra botão único “Aceitar Conversa” em (~1360, 36).
await page.getByRole('button', { name: 'Aceitar Conversa' }).click();
await page.waitForTimeout(1500);
// Esperar o header trocar para os 4 botões (Retornar/Atalho/Resolver/Mais opções)
await page.getByRole('button', { name: 'Resolver' }).waitFor({ state: 'visible', timeout: 5000 });
Confirmação: botão “Resolver” aparece, composer libera o placeholder normal “Comece a escrever uma mensagem.”. Status: [validado] em 2026-05-09 — ciclo completo Aceitar → conversa em Atendente confirmado.
8.4 Enviar mensagem de texto
const composer = page.getByPlaceholder(/Comece a escrever/i).first();
await composer.fill('Olá, em que posso ajudar?');
await page.getByRole('button', { name: 'Enviar' }).click();
await page.waitForTimeout(2000);
Confirmação: a mensagem nova aparece no histórico do lado direito da conversa, com timestamp recente. O texto é prefixado com nome: se a assinatura estiver ativa (botão de assinatura no composer com fundo destacado). Status: [validado] em 2026-05-09 — mensagem com timestamp confirmou aparição no histórico.
8.5 Ligar/desligar assinatura do atendente
Botão índice 2 do composer (em x≈759). Toggle visual — quando ligado, fica com fundo destacado.
// Verificar estado e clicar pra inverter
const compBox = await page.getByPlaceholder(/Comece a escrever/i).boundingBox();
await page.mouse.click(759, compBox.y + compBox.height + 31);
Confirmação: comparar background-color do botão antes/depois. Estado desligado: rgba(0, 0, 0, 0). Estado ligado: rgba(199, 240, 0, 0.15) (amarelo claro com transparência). Status: [validado] em 2026-05-09.
8.6 Inserir resposta rápida ou fluxo (composer)
Atalho: digitar / no campo de mensagem abre a lista de atalhos (placeholder confirma: “Coloque / para acessar os atalhos”). Alternativa: clicar no ícone de raio em (x≈855).
const composer = page.getByPlaceholder(/Comece a escrever/i).first();
await composer.click();
await composer.type('/');
await page.waitForTimeout(800);
// Listar opções e escolher
Status: [validado] em 2026-05-09 — digitar / abre um popover acima do composer listando as respostas rápidas cadastradas. O popover não tem [role=listbox] nem [role=menu]; localize por text= ou pelo container do popper.
8.7 Inserir resposta rápida + fluxos (header — botão “Atalho”)
Botão “Atalho” no header da conversa (em x≈1330) abre um painel lateral completo no lugar do painel CRM (sim, substitui o painel CRM enquanto está aberto), com:
- Header com nome do contato + data de criação + ícone de pena (criar nova) + X (fechar).
- Duas abas: “Mensagens” (respostas rápidas cadastradas) e “Fluxos”.
- Linha de filtros por tipo de conteúdo (texto, arquivo, áudio, imagem, vídeo, etc.).
- Lista das mensagens/fluxos disponíveis com botão de envio direto.
await page.getByRole('button', { name: 'Atalho' }).click();
await page.waitForTimeout(1500);
// Trocar de aba
await page.getByRole('tab', { name: 'Fluxos' }).click();
// Fechar (X no canto superior direito do painel)
Status: [validado] em 2026-05-09 — confirmado que abre painel lateral com abas Mensagens/Fluxos.
8.8 Aplicar etiqueta ao contato
// Click no campo "Etiquetas" (placeholder visível abaixo do header)
await page.getByPlaceholder('Etiquetas').first().click();
await page.waitForTimeout(800);
// O dropdown abre com a lista de etiquetas cadastradas; clicar pra aplicar
await page.locator('.MuiAutocomplete-popper li').filter({ hasText: 'Cliente Ativo' }).first().click();
await page.waitForTimeout(500);
await page.keyboard.press('Escape'); // fecha dropdown
Confirmação: etiqueta aparece como pill colorida na faixa abaixo do header (chip MUI). Para remover, clique na chip — o ícone de X é um SVG, então use dispatchEvent(new MouseEvent('click', { bubbles: true })) em vez de .click() (que falha em SVG). Status: [validado] em 2026-05-09 — aplicação confirmada (chips antes/depois conferem); remoção testada com SVG dispatch.
8.9 Adicionar observação interna no contato
Painel CRM precisa estar aberto (clicar no nome do contato no header — ver 8.23).
A seção “Observações” é um MuiAccordion que já vem expandido por padrão. O + que aparece ao lado do contador “0” abre uma <textarea> inline para digitar a nova observação.
⚠️ Pegadinha: o + está embedado dentro do <button MuiAccordionSummary> que controla o expand/collapse do accordion. page.mouse.click(x, y) na coordenada do + é capturado pelo button do accordion (que apenas COLAPSA a seção). Para acionar o handler do +, dispare o click diretamente no <span> que envolve o SVG, via DOM API:
// Painel CRM já aberto (ver 8.23)
await page.evaluate(() => {
const path = document.querySelector('path[d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"]');
const span = path.closest('span');
span.click();
});
await page.waitForTimeout(1500);
// Aparece <textarea placeholder="Insira aqui a informação que deseja registrar">
const obsTextarea = page.getByPlaceholder('Insira aqui a informação que deseja registrar');
await obsTextarea.fill('Cliente solicitou retorno depois de amanhã');
// (botão de salvar/enviar a observação aparece no painel — confirmar localização e clicar)
Status: [validado] em 2026-05-09 — textarea de criação localizada (placeholder="Insira aqui a informação que deseja registrar", ~273×97 px); botão de salvar a observação [a validar].
8.10 Transferir conversa para outro departamento/atendente
Pelo menu da linha (chevron):
// Hover na linha pra revelar o chevron
const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.waitForTimeout(500);
// Click no chevron (em x≈575 da linha)
const rowBox = await row.boundingBox();
await page.mouse.click(575, rowBox.y + rowBox.height / 2 + 8);
await page.waitForTimeout(1000);
// Click em "Transferir contato"
await page.getByRole('menuitem', { name: 'Transferir contato' }).click();
await page.waitForTimeout(1500);
// Modal de transferência abre — preencher destino
Pelo kebab do header (mais opções): mesmo “Transferir contato” no submenu.
await page.locator('button[aria-label="Mais opções"], button:has(svg path[d*="M12 8c1.1 0 2-.9"])').first().click();
await page.getByRole('menuitem', { name: 'Transferir contato' }).click();
Modal “Transferir Ticket” abre com:
- Input de busca de usuário (
Digite para buscar usuários). - Seletor de departamento —
<input name="queueId">(Transferir para departamento). - Botões: Cancelar, Transferir.
Confirmação após Transferir: conversa some da lista atual (regra de visibilidade por departamento). Status: [validado] em 2026-05-09 — modal abrindo confirmado; transferência efetiva [a validar].
8.11 Marcar como resolvida (Resolver)
Botão verde no header (em x≈1373) — só aparece em conversas Atendente.
await page.getByRole('button', { name: 'Resolver' }).click();
await page.waitForTimeout(3000);
Confirmação: conversa some da aba “Atendente” e aparece em “Resolvidos”. Status: [validado] em 2026-05-09.
8.11a Reabrir conversa resolvida
Em conversas dentro da aba “Resolvidos”, o header tem um botão único “Reabrir” em (~1391, 36).
await page.locator('text=/^Resolvidos/').first().click();
await page.waitForTimeout(1500);
await page.getByText('NOME', { exact: true }).first().click();
await page.waitForTimeout(2500);
await page.getByRole('button', { name: /reabrir/i }).first().click();
Confirmação: conversa volta para Atendente (com o atendente que tinha antes). Status: [validado] em 2026-05-09.
8.12 Devolver para “Aguardando” (Retornar)
await page.getByRole('button', { name: 'Retornar' }).click();
await page.waitForTimeout(3000);
Confirmação: header muda para botão único “Aceitar Conversa”; conversa volta pra fila de Aguardando. Status: [validado] em 2026-05-09.
8.13 Marcar como não lida
const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.waitForTimeout(400);
const rowBox = await row.boundingBox();
await page.mouse.click(575, rowBox.y + rowBox.height / 2 + 8); // chevron
await page.getByRole('menuitem', { name: 'Marcar como não lida' }).click();
⚠️ Item ficar desabilitado (aria-disabled="true"): a opção só fica habilitada quando há mensagem do contato não lida. Se o atendente foi o último a mandar mensagem, ou se todas as mensagens já foram visualizadas, “Marcar como não lida” aparece em cinza no menu e o click é ignorado.
Status: [validado] em 2026-05-09 (item localizado no menu; restrição de habilitação confirmada).
8.14 Fechar conversa (do menu)
Fechar conversa no menu do chevron tem o mesmo efeito que o botão Resolver do header — move a conversa para a aba Resolvidos. Use o que for mais conveniente:
// Caminho A — botão Resolver (mais direto, exige conversa aberta)
await page.getByRole('button', { name: 'Resolver' }).click();
// Caminho B — Fechar conversa via chevron (não exige abrir a conversa)
const row = page.getByText('NOME', { exact: true }).first();
await row.hover();
const chev = await /* ...locate chevron... */;
await page.mouse.click(chev.x, chev.y);
await page.getByRole('menuitem', { name: 'Fechar conversa' }).click();
Status: [validado] em 2026-05-09 — caminho A (Resolver) confirmado movendo para Resolvidos; caminho B presumido equivalente conforme confirmação do usuário.
8.15 Deletar conversa (DESTRUTIVO)
⚠️ Apaga a conversa em definitivo. Sempre exige autorização explícita do usuário — não execute autonomamente em hipótese alguma.
const row = page.getByText('NOME', { exact: true }).first();
await row.hover();
await page.mouse.click(575, /* chevron y */);
await page.getByRole('menuitem', { name: 'Deletar conversa' }).click();
// Provavelmente abre modal de confirmação — capturar e respeitar.
Status: [não validado por decisão] em 2026-05-09 — operação destrutiva, deliberadamente não testada na conversa de exemplo. Quando for executar pela primeira vez, capture e documente o modal de confirmação esperado.
8.16 Enviar fluxo manualmente
const row = page.getByText('Fulano Exemplo', { exact: true }).first();
await row.hover();
await page.mouse.click(575, /* chevron y */);
await page.getByRole('menuitem', { name: 'Enviar fluxo' }).click();
await page.waitForTimeout(1500);
// Modal "Disparar fluxo" abre com:
// - Texto: "Este ticket é individual. Disparo via Flow normal."
// - <input placeholder="Selecione um fluxo"> — autocomplete
// - Botões: Cancelar, Disparar
Status: [validado] em 2026-05-09 — modal abrindo confirmado; disparo [a validar].
8.17 Inscrever em remarketing
await page.getByRole('menuitem', { name: 'Remarketing' }).click();
// Modal "Disparar remarketing" abre com:
// - Label: "Selecione um remarketing"
// - Input autocomplete
// - Botões: Cancelar, Disparar
Status: [validado] em 2026-05-09 — modal abrindo confirmado; disparo [a validar].
8.18 Agendar mensagem
⚠️ Existem dois “Agendar mensagem” com comportamentos diferentes:
Caminho A — Chevron ▾ da linha da conversa (visualização):
// Hover na linha + click no chevron + click "Agendar mensagem"
// Abre modal "Mensagens agendadas" — apenas LISTA dos agendamentos existentes.
// Sem botão "Nova" ou form de criação. Use só para conferir o que já está agendado.
Caminho B — Kebab ⋮ do header da conversa (criação):
await page.getByText('Fulano Exemplo', { exact: true }).first().click(); // garante conversa aberta
await page.waitForTimeout(2000);
await page.mouse.click(1417, 36); // kebab do header (ao lado de Resolver)
await page.waitForTimeout(1000);
await page.getByRole('menuitem', { name: 'Agendar mensagem' }).click();
await page.waitForTimeout(2000);
// Modal "Mensagem Pendente" abre com:
// - <textarea name="body"> — mensagem
// - <input type="datetime-local" name="sendAt"> — data e hora
// - Botões: fechar (aria-label="fechar"), Cancelar, Adicionar
await page.locator('textarea[name="body"]').fill('Mensagem agendada via runbook');
await page.locator('input[name="sendAt"]').fill('2026-05-10T10:00');
await page.getByRole('button', { name: 'Adicionar' }).click();
Confirmação: após “Adicionar”, a mensagem aparece na lista quando você abre o modal de visualização (Caminho A).
Status: [validado] em 2026-05-09 — caminho B confirmado abrindo o formulário “Mensagem Pendente”; criar registro em si [a validar].
8.19 Buscar conversa por nome ou telefone
const search = page.getByPlaceholder('Encontre conversas');
await search.fill('55119XXXXXXX');
await page.waitForTimeout(800);
// Lista filtra em tempo real
A busca afeta todas as abas simultaneamente (Atendente/Aguardando/Resolvidos) — os badges das abas são recalculados em tempo real pra refletir os matches dentro de cada uma.
Status: [validado] em 2026-05-09.
8.20 Filtrar a lista por etiqueta
// Clica no ícone de etiqueta (em x≈311, y≈114)
await page.mouse.click(311, 114);
await page.waitForTimeout(800);
// Dropdown abre; clicar na etiqueta desejada
await page.locator('text=Cliente Ativo').first().click();
await page.keyboard.press('Escape');
Status: [validado] para abrir; aplicar filtro [a validar].
8.21 Filtrar por atendente
Mesmo padrão da etiqueta, mas com clique em (347, 114).
8.22 Filtrar por departamento/WhatsApp/status (funil)
await page.getByRole('button', { name: 'Filtros' }).click();
await page.waitForTimeout(800);
// Dropdown com Departamentos / WhatsApps / Status
await page.getByText('Departamentos', { exact: true }).click();
// Lista de departamentos disponíveis abre — clicar no desejado
Status: [validado] para abertura; seleção [a validar].
8.23 Abrir painel CRM e editar dados do contato
⚠️ Sempre é necessário clicar no nome do contato no header para abrir o painel — o tamanho do viewport não importa. A premissa anterior de que viewport ≥ 1700 abriria automaticamente estava errada.
// 1. Clicar no nome do contato no header (no topo da conversa, y < 50)
const headerName = await page.evaluate(() => {
const c = Array.from(document.querySelectorAll('p, span'))
.find((el) => el.textContent?.trim() === 'Fulano Exemplo' && el.getBoundingClientRect().top < 50);
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
});
await page.mouse.click(headerName.x, headerName.y);
await page.waitForTimeout(2500);
// 2. Click no lápis (Material Edit path "M3 17.25V21")
const editBtn = page.locator('button').filter({
has: page.locator('svg path[d^="M3 17.25V21"]')
}).first();
await editBtn.scrollIntoViewIfNeeded();
await editBtn.click();
Modal “Editar contato” abre com:
- Labels: “Dados do contato”, “Nome”, “E-mail”, “Informações adicionais”.
<input name="name">com nome atual.<input type="tel">com placeholder formato BR+55 (11) 99877-4455.<input name="email">.- Botões: “adicionar informação” (campos personalizados), “Cancelar”, “Salvar”.
Status: [validado] em 2026-05-09 — modal abrindo confirmado; salvar [a validar].
8.24 Acessar menu “Mais opções” da lista (Novo atendimento, Chat interno, Agendamento)
Os três itens têm comportamentos diferentes:
- Novo atendimento: abre modal “Criar atendimento” com input “Digite para pesquisar o contato” + Cancelar/Salvar.
- Chat interno: substitui a área da conversa por um painel embutido “Chat Interno” (não é dialog). Tem botão verde ”+ Novo” pra iniciar conversa com a equipe; estado vazio mostra “Inicie uma nova conversa interna” e “Chat não encontrado”.
- Agendamento: substitui a área da conversa por um painel embutido “Agendamentos” (não é dialog). Tem botão verde ”+ Novo Agendamento” no canto superior direito + barra de pesquisa; estado vazio mostra “Nenhum agendamento encontrado”.
// Abrir Novo atendimento (modal)
await page.getByRole('button', { name: 'Mais opções' }).click();
await page.getByRole('menuitem', { name: 'Novo atendimento' }).click();
await page.waitForTimeout(2000);
// Abrir Chat interno (painel embutido)
await page.getByRole('button', { name: 'Mais opções' }).click();
await page.getByRole('menuitem', { name: 'Chat interno' }).click();
await page.waitForTimeout(2000);
// Pra fechar e voltar pra conversa: clicar em uma conversa da lista, ou navegar
await page.getByText('Fulano Exemplo', { exact: true }).first().click();
Status: [validado] em 2026-05-09.
9. Detecção de sucesso (toasts MUI)
O Sagazchat usa toasts no canto superior direito (acima do painel CRM ou no canto da tela) para confirmar ações. Exemplos vistos:
- “Login efetuado com sucesso!”
- “Fluxo desta conversa foi removido!”
Padrão de captura:
const toast = await page.locator('[role=alert], .MuiAlert-root, .Toastify__toast').first();
const text = await toast.textContent({ timeout: 5000 }).catch(() => null);
console.log('toast:', text);
Para esperar sucesso depois de uma ação destrutiva, prefira: Promise.race([toastShown(), errorShown(), timeout]).
10. Recuperação e tratamento de erros
- Click acidental em botão disparador (gravar áudio, etc.): procure ícone de lixeira/cancelar dentro da própria UI antes de recarregar. Recarregar é último recurso porque pode descartar trabalho não salvo.
- Conversa some da lista após ação: verificar regras de departamento (seção 6) — após transferência, atendentes do antigo perdem acesso na hora.
- Timeout em
getByText('Atendente', exact: true): o texto inclui o badge. Use regextext=/^Atendente/. browser.contexts()[0]retornaundefinedao conectar: você usouchromium.connectem vez dechromium.connectOverCDP. Troque.- Painel CRM existe no DOM mas não aparece visualmente: viewport pequena. Aumente para ≥ 1700 com
page.setViewportSizeou clique no nome do contato. - Avast bloqueia download: se for de gyan.dev (redirect), troque pra URL direta do GitHub release. Se for
npm install, peça pra desativar HTTPS scanning temporariamente.
11. Como iniciar uma nova auditoria do zero
# 1. Iniciar sessão persistente (background)
$env:SAGAZ_EMAIL='...'; $env:SAGAZ_PASSWORD='...';
node .\scripts\sagaz-session.mjs # rodar com run_in_background no harness
# 2. Aguardar scripts/_out/cdp.txt aparecer
# 3. Rodar a fase desejada
node .\scripts\sagaz-audit-<fase>.mjs
# 4. Ao terminar, parar a sessão (TaskStop no harness)