mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(realtime): a rede de segurança — cura a perda E denuncia a falha
Uma peça com três papéis, e ela responde ao único achado do dia que atravessou
três retratações intacto: QUANDO A ENTREGA MORRE, NENHUMA TELA AVISA.
1. CURA. Hoje, se o canal para de entregar, o board NUNCA se recupera — nem ao
voltar para a aba. A tela fica congelada num passado que parece presente, e
só um F5 conserta.
2. DENUNCIA. Se o refetch traz estado diferente e o canal não entregou nada no
intervalo, alguma coisa se perdeu. É a única forma de SABER que morreu.
3. DESTRAVA a cerca. Sem o sinal "houve entrega recente?", divergência é
indistinguível de "nada aconteceu", e a verificação só consegue REPROVAR.
⚠️ NÃO É "refetch periódico com outro nome". A diferença é o COMPARADOR: um
refetch periódico ESCONDE a perda (o dado volta e ninguém soube que faltou);
este a denuncia. Curar sem detectar seria pior que o estado atual — a falha
viraria invisível em vez de visível-tarde, e ninguém consertaria a causa.
E ele tem uma vantagem que o realtime não tem: NÃO PODE nascer anônimo nem
morrer calado. É requisição com resposta. O canal responde SUBSCRIBED e pode
nunca entregar nada, em silêncio — foi exatamente isso que custou o dia.
PROVA — O PAR, matando a entrega de verdade:
canal trouxe card apareceu divergências
entrega MORTA não SIM 0 → 1
entrega VIVA sim sim 0 → 0
Só o lado morto mostraria a cura sem distinguir "a rede funcionou" de "o canal
funcionou". Só o lado vivo não mostraria nada. Juntos: cura nos dois casos,
denuncia só quando há o que denunciar — um detector que gritasse sempre seria
desligado na primeira semana.
MATAR A ENTREGA EXIGIU O APARATO CERTO: a primeira versão usou `page.route`, que
intercepta HTTP e NÃO WebSocket. O canal entregou normalmente e o "curou: sim"
era o realtime funcionando. Aparato que não produz a condição mede outra coisa e
devolve verde que parece prova. Agora é `addInitScript` substituindo o construtor
de `WebSocket` só para URLs de realtime.
E O COMPARADOR TAMBÉM NASCEU ERRADO: comparava com um `entregaAntes!` que, sendo
`null`, virava `>= 0` — sempre verdadeiro. O detector acusaria divergência em
toda mudança legítima. Agora o critério são duas perguntas explícitas: a tela
mudou? o canal trouxe algo desde a última vez?
As divergências ficam OBSERVÁVEIS na tela (`data-refetch-divergencias`), pelo
mesmo motivo do status do canal: "a entrega morreu" e "nada aconteceu" têm a
mesma aparência, que é silêncio.
typecheck 0 · lint 0 erros · 416 testes em 69 arquivos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dxBNZMjUzBx8xWs8DvGvP
This commit is contained in:
co-authored by
Claude Opus 5
parent
328a3a919c
commit
25fb56d40e
@@ -35,7 +35,7 @@ export function PipelinePageClient({
|
||||
pipelineId: string;
|
||||
initialName: string;
|
||||
}) {
|
||||
const { data, isLoading, error, pulses, realtimeStatus } = useBoard(pipelineId);
|
||||
const { data, isLoading, error, pulses, realtimeStatus, seguranca } = useBoard(pipelineId);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -66,6 +66,13 @@ export function PipelinePageClient({
|
||||
// Ainda NÃO religa — religar é desenho e merece bloco próprio. Isto aqui
|
||||
// é só parar de descartar o que já era calculado.
|
||||
data-realtime-status={realtimeStatus.toLowerCase()}
|
||||
// A rede de segurança fica OBSERVÁVEL pelo mesmo motivo do status do
|
||||
// canal: "a entrega morreu" e "nada aconteceu" têm a mesma aparência, que
|
||||
// é silêncio. Aqui o número de divergências é a diferença entre os dois —
|
||||
// e é o sinal que faltava para uma verificação poder APROVAR, e não só
|
||||
// reprovar.
|
||||
data-refetch-divergencias={seguranca.divergencias}
|
||||
data-refetch-em={seguranca.ultimaVerificacao ?? ""}
|
||||
>
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
|
||||
@@ -152,3 +152,27 @@ outro não acompanhou, sem nada reclamar.**
|
||||
68, sem contato). Eles continuam esfriando e aparecendo no radar; a saída deles
|
||||
é humana. Isso não é regressão — é uma lacuna que só apareceu porque a proposta
|
||||
passou a existir para revelá-la.
|
||||
|
||||
---
|
||||
|
||||
## A rede de segurança (fora do escopo da wave, e a peça mais valiosa dela)
|
||||
|
||||

|
||||
|
||||
Uma peça com três papéis, e ela responde ao único achado do dia que atravessou
|
||||
três retratações intacto: **quando a entrega morre, nenhuma tela avisa**.
|
||||
|
||||
| | canal trouxe | o card apareceu | divergências |
|
||||
|---|---|---|---|
|
||||
| entrega **morta** | não | **sim** | 0 → **1** |
|
||||
| entrega **viva** (controle) | sim | sim | 0 → **0** |
|
||||
|
||||
O par é o que prova. Só o lado morto mostraria a cura e não distinguiria "a rede
|
||||
funcionou" de "o canal funcionou"; só o lado vivo não mostraria nada. Juntos:
|
||||
**cura nos dois casos, denuncia só quando há o que denunciar** — um detector que
|
||||
gritasse sempre seria desligado na primeira semana.
|
||||
|
||||
Matar a entrega exigiu `addInitScript` substituindo o construtor de `WebSocket`.
|
||||
A primeira versão usou `page.route`, que intercepta HTTP e **não** WebSocket: o
|
||||
canal entregou normalmente, e o "curou: sim" era o realtime funcionando. Aparato
|
||||
que não produz a condição mede outra coisa e devolve um verde que parece prova.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ehEcoLocal } from "@/lib/kanban/local-echo";
|
||||
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
|
||||
import { useRefetchDeSeguranca } from "@/hooks/realtime/useRefetchDeSeguranca";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { BoardData } from "@/lib/kanban/types";
|
||||
|
||||
@@ -119,7 +120,7 @@ export function useBoard(pipelineId: string | null) {
|
||||
// Assinatura que morre calada é peça sem sinal de vida: o "log morto" do
|
||||
// checklist do sistema vivo, na versão cara, porque a tela continua parecendo
|
||||
// certa enquanto o board já não escuta mais nada.
|
||||
const { status: realtimeStatus } = useRealtimeChannel({
|
||||
const { status: realtimeStatus, ultimaEntrega } = useRealtimeChannel({
|
||||
name: pipelineId ? `kanban-${pipelineId}` : "kanban-disabled",
|
||||
postgresChanges: pipelineId
|
||||
? {
|
||||
@@ -143,5 +144,25 @@ export function useBoard(pipelineId: string | null) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...query, pulses, realtimeStatus };
|
||||
// A REDE DE SEGURANÇA. Sem ela, um canal que para de entregar deixa o board
|
||||
// congelado num passado que parece presente — e nem voltar para a aba
|
||||
// conserta. A assinatura é a contagem de leads mais o maior `updated_at`: é
|
||||
// sensível a exatamente o que o canal deveria ter trazido (lead novo, lead
|
||||
// movido, lead editado) e barata de calcular a cada verificação.
|
||||
const seguranca = useRefetchDeSeguranca<BoardData>({
|
||||
queryKey,
|
||||
assinatura: (d) => {
|
||||
const leads = d?.leads ?? [];
|
||||
let maior = "";
|
||||
for (const l of leads) {
|
||||
const u = (l as { updated_at?: string }).updated_at ?? "";
|
||||
if (u > maior) maior = u;
|
||||
}
|
||||
return `${leads.length}:${maior}`;
|
||||
},
|
||||
ultimaEntrega,
|
||||
enabled: !!pipelineId,
|
||||
});
|
||||
|
||||
return { ...query, pulses, realtimeStatus, seguranca };
|
||||
}
|
||||
|
||||
@@ -118,7 +118,11 @@ function esperarAuth(supabase: ReturnType<typeof createClient>): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: RealtimeStatus } {
|
||||
export function useRealtimeChannel(opts: UseRealtimeChannelOpts): {
|
||||
status: RealtimeStatus;
|
||||
/** Instante da última entrega deste canal, ou null se nunca entregou nada. */
|
||||
ultimaEntrega: number | null;
|
||||
} {
|
||||
const { name, postgresChanges, broadcast, onChange, enabled = true } = opts;
|
||||
|
||||
// ref makes onChange identity-stable so changing handler doesn't re-subscribe
|
||||
@@ -127,6 +131,19 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: Real
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
/**
|
||||
* QUANDO este canal entregou algo pela última vez.
|
||||
*
|
||||
* Existe para o refetch de segurança poder responder "houve entrega
|
||||
* recente?" — sem esse sinal, uma diferença entre o que o servidor tem e o
|
||||
* que a tela mostra é indistinguível de "nada aconteceu no intervalo", e a
|
||||
* checagem só consegue REPROVAR, nunca aprovar.
|
||||
*
|
||||
* `useRef` e não `useState`: isto não redesenha nada, e virar dependência de
|
||||
* efeito faria o canal re-assinar a cada evento — perdendo eventos na janela.
|
||||
*/
|
||||
const ultimaEntrega = useRef<number | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<RealtimeStatus>(enabled ? "connecting" : "closed");
|
||||
|
||||
// React 19 strict mode mounts effects twice in dev. If two consumers ever
|
||||
@@ -149,6 +166,10 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: Real
|
||||
let chain: RealtimeChannel = supabase.channel(channelName);
|
||||
|
||||
const handler = (payload: unknown) => {
|
||||
// Carimba ANTES de entregar: se o consumidor lançar, a entrega ainda
|
||||
// aconteceu — e o refetch de segurança precisa saber disso para não
|
||||
// acusar o canal de ter perdido o que ele trouxe.
|
||||
ultimaEntrega.current = Date.now();
|
||||
onChangeRef.current(payload);
|
||||
};
|
||||
|
||||
@@ -201,5 +222,5 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: Real
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name, enabled, instanceId, postgresChanges?.event, postgresChanges?.table, postgresChanges?.filter, postgresChanges?.schema, broadcast?.event]);
|
||||
|
||||
return { status };
|
||||
return { status, ultimaEntrega: ultimaEntrega.current };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* O REFETCH DE SEGURANÇA — uma peça com três papéis.
|
||||
*
|
||||
* 1. CURA a perda. Hoje, quando o realtime deixa de entregar, board e dossiê
|
||||
* NUNCA se recuperam — nem ao voltar para a aba. A tela fica congelada num
|
||||
* passado que parece presente, e só um F5 conserta. Aqui ela volta sozinha.
|
||||
*
|
||||
* 2. DETECTA a falha. Se o refetch traz um estado DIFERENTE do que está na
|
||||
* tela e o canal não entregou nada no intervalo, alguma coisa se perdeu no
|
||||
* caminho. Essa é a única forma de saber que a entrega morreu — e é a
|
||||
* resposta ao único achado do dia que atravessou três retratações intacto:
|
||||
* **quando a entrega morre, nenhuma tela avisa**.
|
||||
*
|
||||
* 3. DESTRAVA a cerca. Uma verificação que só sabe dizer "o dado não chegou"
|
||||
* só consegue REPROVAR: sem o sinal "houve entrega recente?", divergência é
|
||||
* indistinguível de "nada aconteceu no intervalo".
|
||||
*
|
||||
* ⚠️ E ELE TEM UMA VANTAGEM QUE O REALTIME NÃO TEM: não pode nascer anônimo nem
|
||||
* morrer calado. É requisição com resposta — ou volta, ou dá erro. O canal
|
||||
* responde SUBSCRIBED e pode nunca entregar nada, em silêncio, e foi
|
||||
* exatamente isso que custou o dia.
|
||||
*/
|
||||
|
||||
export interface RefetchDeSeguranca {
|
||||
/** Quantas vezes o refetch trouxe novidade que o canal não tinha entregue. */
|
||||
divergencias: number;
|
||||
/** Instante da última divergência — null enquanto tudo bate. */
|
||||
ultimaDivergencia: number | null;
|
||||
/** Quando o refetch rodou pela última vez. */
|
||||
ultimaVerificacao: number | null;
|
||||
}
|
||||
|
||||
interface Opts<T> {
|
||||
queryKey: readonly unknown[];
|
||||
/**
|
||||
* O que caracteriza o estado da tela — contagem, maior `updated_at`, o que
|
||||
* mudar quando algo muda. NÃO precisa ser o dado inteiro: precisa ser
|
||||
* sensível ao que o realtime deveria ter trazido.
|
||||
*/
|
||||
assinatura: (dado: T | undefined) => string;
|
||||
/** Instante da última entrega do canal (de `useRealtimeChannel`). */
|
||||
ultimaEntrega: number | null;
|
||||
/** De quanto em quanto tempo conferir. */
|
||||
intervaloMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ NÃO É "só um refetch periódico com outro nome". A diferença é o
|
||||
* COMPARADOR: um refetch periódico esconde a perda (o dado volta e ninguém sabe
|
||||
* que faltou); este a DENUNCIA, porque compara o antes com o depois e sabe se o
|
||||
* canal tinha entregue algo no meio.
|
||||
*
|
||||
* Curar sem detectar seria pior que o estado atual — a falha vira invisível
|
||||
* em vez de visível-tarde, e ninguém consertaria a causa.
|
||||
*/
|
||||
export function useRefetchDeSeguranca<T>({
|
||||
queryKey,
|
||||
assinatura,
|
||||
ultimaEntrega,
|
||||
intervaloMs = 45_000,
|
||||
enabled = true,
|
||||
}: Opts<T>): RefetchDeSeguranca {
|
||||
const qc = useQueryClient();
|
||||
const [estado, setEstado] = useState<RefetchDeSeguranca>({
|
||||
divergencias: 0,
|
||||
ultimaDivergencia: null,
|
||||
ultimaVerificacao: null,
|
||||
});
|
||||
// Refs para o efeito não re-montar a cada render e o intervalo não reiniciar
|
||||
// — reiniciar adiaria a verificação para sempre numa tela que redesenha.
|
||||
const assinaturaRef = useRef(assinatura);
|
||||
assinaturaRef.current = assinatura;
|
||||
const entregaRef = useRef(ultimaEntrega);
|
||||
entregaRef.current = ultimaEntrega;
|
||||
|
||||
const verificar = useCallback(async () => {
|
||||
const antes = assinaturaRef.current(qc.getQueryData<T>(queryKey));
|
||||
|
||||
await qc.refetchQueries({ queryKey, exact: true });
|
||||
|
||||
const depois = assinaturaRef.current(qc.getQueryData<T>(queryKey));
|
||||
|
||||
setEstado((prev) => {
|
||||
// O CRITÉRIO, em duas perguntas:
|
||||
// a tela mudou depois de reler o servidor? → `mudou`
|
||||
// o canal trouxe alguma coisa desde a última vez? → `canalTrouxe`
|
||||
//
|
||||
// Mudou E o canal não trouxe nada = ele PERDEU. Se o canal trouxe, a
|
||||
// mudança tem dono e não há divergência: a tela mudou porque foi avisada.
|
||||
//
|
||||
// A primeira versão comparava com `entregaAntes!` e um `null` fazia a
|
||||
// comparação virar `>= 0`, que é sempre verdadeira — o detector acusaria
|
||||
// divergência em toda mudança legítima, e um detector que grita sempre é
|
||||
// desligado na primeira semana.
|
||||
const mudou = depois !== antes;
|
||||
const canalTrouxe =
|
||||
entregaRef.current !== null &&
|
||||
(prev.ultimaVerificacao === null || entregaRef.current > prev.ultimaVerificacao);
|
||||
const perdeu = mudou && !canalTrouxe;
|
||||
return {
|
||||
divergencias: perdeu ? prev.divergencias + 1 : prev.divergencias,
|
||||
ultimaDivergencia: perdeu ? Date.now() : prev.ultimaDivergencia,
|
||||
ultimaVerificacao: Date.now(),
|
||||
};
|
||||
});
|
||||
}, [qc, queryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const timer = setInterval(() => void verificar(), intervaloMs);
|
||||
|
||||
// AO VOLTAR PARA A ABA, imediatamente: é o momento em que o usuário mais
|
||||
// acredita no que vê, e é justamente quando a tela pode estar mais velha —
|
||||
// o socket cai em aba de fundo e ninguém avisa.
|
||||
const aoVoltar = () => {
|
||||
if (document.visibilityState === "visible") void verificar();
|
||||
};
|
||||
document.addEventListener("visibilitychange", aoVoltar);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", aoVoltar);
|
||||
};
|
||||
}, [enabled, intervaloMs, verificar]);
|
||||
|
||||
return estado;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* A REDE DE SEGURANÇA, provada matando a entrega.
|
||||
*
|
||||
* O cenário é o defeito do dia: o canal está vivo (SUBSCRIBED) e não entrega
|
||||
* nada. Hoje o board fica congelado num passado que parece presente — nem
|
||||
* voltar para a aba conserta.
|
||||
*
|
||||
* Como se mata a entrega SEM mexer no código sob teste: bloqueando o WebSocket
|
||||
* do lado do navegador. O canal nunca recebe evento, e o resto da página segue
|
||||
* igual — que é exatamente a condição real.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
import { BASE, CARD_ATTR, login } from "./qa-helpers";
|
||||
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync(".env.local", "utf8").split("\n")
|
||||
.filter((l) => l.includes("=") && !l.trimStart().startsWith("#"))
|
||||
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1).replace(/^"|"$/g, "")]),
|
||||
);
|
||||
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
const ORG = "6e567068-fd1c-4f94-ae1f-40e0334be190";
|
||||
const PIPE = "35bf4ac9-c5e0-4f7d-846a-99b1bcc92d69";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
await login(page, "manager");
|
||||
|
||||
// MATA A ENTREGA — e a primeira versão desta sonda FALHOU aqui: usei
|
||||
// `page.route`, que intercepta HTTP e NÃO WebSocket. O canal entregou
|
||||
// normalmente e o teste mediu o canal, não a rede: "curou" era o realtime
|
||||
// funcionando. Aparato que não produz a condição mede outra coisa e devolve
|
||||
// um verde que parece prova.
|
||||
//
|
||||
// `addInitScript` roda ANTES do app: o construtor de WebSocket passa a
|
||||
// rejeitar as URLs de realtime, e nada mais na página muda.
|
||||
const MATAR = process.env.ENTREGA !== "viva";
|
||||
if (MATAR) await page.addInitScript(() => {
|
||||
const Original = window.WebSocket;
|
||||
window.WebSocket = function (url: string | URL, protocols?: string | string[]) {
|
||||
if (String(url).includes("/realtime/")) {
|
||||
throw new Error("[sonda] WebSocket de realtime bloqueado de propósito");
|
||||
}
|
||||
return new Original(url, protocols);
|
||||
} as unknown as typeof WebSocket;
|
||||
window.WebSocket.prototype = Original.prototype;
|
||||
});
|
||||
console.info(MATAR ? "modo: ENTREGA MORTA" : "modo: ENTREGA VIVA (controle)");
|
||||
|
||||
await page.goto(`${BASE}/app/pipelines/${PIPE}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const board = page.locator("[data-refetch-divergencias]").first();
|
||||
const antes = {
|
||||
divergencias: await board.getAttribute("data-refetch-divergencias"),
|
||||
cards: await page.locator(`[${CARD_ATTR}]`).count(),
|
||||
};
|
||||
console.info(`ANTES · cards=${antes.cards} · divergências=${antes.divergencias}`);
|
||||
|
||||
// Um lead NOVO entra pelo banco. Com a entrega morta, o canal não avisa.
|
||||
const { data: st } = await admin
|
||||
.from("crm_stages").select("id").eq("pipeline_id", PIPE).order("position").limit(1).maybeSingle();
|
||||
const leadId = randomUUID();
|
||||
await admin.from("crm_leads").insert({
|
||||
id: leadId, organization_id: ORG, pipeline_id: PIPE,
|
||||
stage_id: (st as { id: string }).id,
|
||||
title: `rede de seguranca ${new Date().toISOString().slice(11, 19)}`,
|
||||
position_in_stage: 1,
|
||||
});
|
||||
console.info("· lead criado com a ENTREGA MORTA (websocket bloqueado)");
|
||||
|
||||
// 8s: o canal teria entregue em ~2s se estivesse vivo.
|
||||
await page.waitForTimeout(8000);
|
||||
const semRede = await page.locator(`[${CARD_ATTR}="${leadId}"]`).count();
|
||||
console.info(`1. o canal trouxe? ${semRede ? "SIM" : "NÃO"} ${MATAR ? (semRede ? "← o bloqueio falhou" : "— morta, como planejado") : (semRede ? "— viva, como esperado" : "← o canal deveria ter trazido")}`);
|
||||
|
||||
// A REDE roda a cada 45s; volto para a aba para exercitar o outro gatilho.
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const depois = {
|
||||
divergencias: await board.getAttribute("data-refetch-divergencias"),
|
||||
apareceu: await page.locator(`[${CARD_ATTR}="${leadId}"]`).count(),
|
||||
};
|
||||
console.info(`2. a rede CUROU? ${depois.apareceu ? "SIM — o card apareceu sem reload" : "NÃO ← a tela segue congelada"}`);
|
||||
console.info(`3. a rede DENUNCIOU? divergências ${antes.divergencias} → ${depois.divergencias}`);
|
||||
await page.screenshot({ path: "evidence/wave7-rede-de-seguranca.png" });
|
||||
|
||||
await admin.from("crm_leads").delete().eq("id", leadId);
|
||||
await browser.close();
|
||||
}
|
||||
void main();
|
||||
Reference in New Issue
Block a user