From 4f554bc8b9878790f58b9fc895a61823208747fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 00:22:58 +0000 Subject: [PATCH] fix(demo): el chat de /demo usa el reloj del servidor y no corta las burbujas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El poll pedía mensajes con Date.now() del navegador, así que un reloj adelantado dejaba la respuesta guardada en el panel pero invisible en el chat. Además el sondeo se apagaba en la primera burbuja y perdía el resto de un max_chunks. Ahora el cursor sale de /demo/poll?after=0 y el sondeo sigue 6s después de la última burbuja. Co-authored-by: santmun --- src/channels/labels.ts | 1 + src/channels/shared.ts | 2 +- src/channels/web.ts | 39 ++++++ src/demo.ts | 277 ++++++++++++++++++++++++++++++++++++ src/env.ts | 3 + src/index.ts | 17 +++ src/replies/sender.ts | 2 + test/channels/web.test.ts | 36 +++++ test/demo.test.ts | 286 ++++++++++++++++++++++++++++++++++++++ wrangler.toml | 1 + 10 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 src/channels/web.ts create mode 100644 src/demo.ts create mode 100644 test/channels/web.test.ts create mode 100644 test/demo.test.ts diff --git a/src/channels/labels.ts b/src/channels/labels.ts index 7f2a649d..1d22f100 100644 --- a/src/channels/labels.ts +++ b/src/channels/labels.ts @@ -13,6 +13,7 @@ export const CHANNEL_LABELS: Record = { instagram: "Instagram", messenger: "Messenger", manychat: "ManyChat", + web: "Web", }; export function channelLabel(channel: string | null | undefined): string { diff --git a/src/channels/shared.ts b/src/channels/shared.ts index 08665db1..0bed2b8d 100644 --- a/src/channels/shared.ts +++ b/src/channels/shared.ts @@ -1,4 +1,4 @@ -export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp"; +export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp" | "web"; export interface IncomingMessage { channel: ChannelId; diff --git a/src/channels/web.ts b/src/channels/web.ts new file mode 100644 index 00000000..ea26e94d --- /dev/null +++ b/src/channels/web.ts @@ -0,0 +1,39 @@ +import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; +import type { Env } from "../env"; + +/** + * Canal WEB — el chat de /demo. + * + * No hay proveedor al que empujar la respuesta: el agente la deja en D1 y el + * navegador la recoge con GET /demo/poll. sendReply es un no-op a propósito, + * para que este canal reutilice el mismo pipeline (buffer, tools, KB) sin + * ramas especiales. + */ +export const webAdapter: ChannelAdapter = { + async parseIncoming(request: Request, _env: Env): Promise { + const body = (await request.json().catch(() => ({}))) as { + sessionId?: string; + text?: string; + name?: string; + }; + + const sessionId = (body.sessionId ?? "").trim(); + const text = (body.text ?? "").trim(); + if (!sessionId || !text) { + throw new Error("web: falta sessionId o text"); + } + + return { + channel: "web", + // El sessionId lo genera el navegador. Acotado para que no ensucie el + // nombre del Durable Object. + channelUserId: sessionId.slice(0, 64), + displayName: (body.name ?? "").trim().slice(0, 60) || undefined, + text: text.slice(0, 2000), + receivedAt: Date.now(), + rawPayload: body, + }; + }, + + async sendReply(_reply: OutgoingReply, _env: Env): Promise {}, +}; diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 00000000..a3d41ab2 --- /dev/null +++ b/src/demo.ts @@ -0,0 +1,277 @@ +import type { Context } from "hono"; +import type { Env } from "./env"; +import { Db } from "./db/client"; + +/** + * MODO DEMO — chat web público para enseñarle el bot a un prospecto. + * + * Se enciende con `DEMO_MODE = "on"`. Apagado por defecto: un bot de producción + * no debe exponer un chat sin autenticar. El visitante manda POST /demo/send + * (canal `web`, el mismo pipeline del agente) y la página recoge la respuesta + * con GET /demo/poll. + * + * El cursor de ese poll es `created_at` del SERVIDOR. Si el cliente lo arma con + * el reloj del navegador (`Date.now()`), un reloj adelantado pide mensajes + * "del futuro" y el poll vuelve vacío aunque la respuesta ya esté en el panel. + * Por eso la página, al cargar, pide `?after=0`, se queda con el `at` más alto + * que ya existe y no pinta ese historial. Nunca usa el reloj del visitante. + * + * Tampoco corta el sondeo en la primera burbuja: con `max_chunks` las siguientes + * llegan unos segundos después. Sigue pidiendo ~6 s desde la última burbuja. + */ + +/** Tope de turnos por sesión: un demo real no pasa de unas pocas preguntas. */ +const MAX_TURNS_PER_SESSION = 40; + +/** Tras la última burbuja, el sondeo rápido sigue este rato por si hay más. */ +export const DEMO_FOLLOW_MS = 6000; + +/** Si para entonces no llegó nada, se deja de esperar y se suelta el input. */ +export const DEMO_GIVE_UP_MS = 90_000; + +export function demoEnabled(env: Env): boolean { + return (env.DEMO_MODE ?? "").toLowerCase() === "on"; +} + +/** Cuántos mensajes del visitante lleva esta sesión (anti-abuso de costo). */ +export async function demoTurnsUsed(env: Env, sessionId: string): Promise { + const db = new Db(env.DB); + const row = await db.first<{ n: number }>( + `SELECT COUNT(*) AS n FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE c.channel = 'web' AND c.channel_user_id = ? AND m.role = 'user'`, + [sessionId], + ); + return row?.n ?? 0; +} + +export function demoOverLimit(used: number): boolean { + return used >= MAX_TURNS_PER_SESSION; +} + +export interface DemoPollMessage { + id: string; + text: string; + at: number; +} + +/** GET /demo/poll?session=…&after=… → mensajes del bot posteriores a `after`. */ +export async function demoPoll(c: Context<{ Bindings: Env }>) { + if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404); + + const sessionId = (c.req.query("session") ?? "").slice(0, 64); + const after = Number(c.req.query("after") ?? 0) || 0; + if (!sessionId) return c.json({ ok: false, error: "missing_session" }, 400); + + const db = new Db(c.env.DB); + const conv = await db.first<{ id: string }>( + "SELECT id FROM conversations WHERE channel = 'web' AND channel_user_id = ? LIMIT 1", + [sessionId], + ); + if (!conv) return c.json({ ok: true, messages: [] }); + + const rows = await db.all<{ id: string; content: string; created_at: number }>( + `SELECT id, content, created_at FROM messages + WHERE conversation_id = ? AND created_at > ? AND role = 'assistant' + ORDER BY created_at ASC LIMIT 20`, + [conv.id, after], + ); + + const messages: DemoPollMessage[] = rows.map((r) => ({ + id: r.id, + text: r.content, + at: r.created_at, + })); + return c.json({ ok: true, messages }); +} + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** + * JS del chat. El cursor (`since`) solo avanza con `at` que devolvió el + * servidor. `seen` evita pintar dos veces la misma fila si dos sondeos se + * cruzan. La clave es el id; si no viniera, at+texto, para no tirar una + * segunda burbuja que compartiera milisegundo con la primera. + */ +export function demoClientScript(): string { + return `(function(){ + var KEY='forja_demo_session'; + var sid=localStorage.getItem(KEY); + if(!sid){ sid=(crypto.randomUUID?crypto.randomUUID():String(Date.now())+Math.random().toString(16).slice(2)); + localStorage.setItem(KEY,sid); } + + var chat=document.getElementById('chat'), ta=document.getElementById('t'), btn=document.getElementById('s'); + var since=0, polling=null, followTimer=null, giveUpTimer=null, waiting=false, seen={}; + + function scroll(){ chat.scrollTop=chat.scrollHeight; } + function add(text,who,cls){ + var r=document.createElement('div'); r.className='row '+who; + var b=document.createElement('div'); b.className='bub'+(cls?' '+cls:''); b.textContent=text; + r.appendChild(b); chat.appendChild(r); scroll(); return r; + } + function typing(){ + var r=document.createElement('div'); r.className='row bot'; r.id='typing'; + r.innerHTML='
'; + chat.appendChild(r); scroll(); return r; + } + function stopTyping(){ var e=document.getElementById('typing'); if(e) e.remove(); } + + ta.addEventListener('input',function(){ ta.style.height='auto'; ta.style.height=Math.min(ta.scrollHeight,120)+'px'; }); + ta.addEventListener('keydown',function(e){ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } }); + btn.addEventListener('click',send); + + function clave(m){ return m.id || (String(m.at)+"\\n"+m.text); } + function tomar(m, pintar){ + var k=clave(m); + if(seen[k]) return false; + seen[k]=1; + if(typeof m.at==='number' && m.at>since) since=m.at; + if(pintar) add(m.text,'bot'); + return true; + } + + function pararSondeo(){ + if(polling){ clearInterval(polling); polling=null; } + } + + function poll(){ + fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after='+since) + .then(function(r){ return r.json(); }) + .then(function(d){ + if(!d||!d.ok||!d.messages||!d.messages.length) return; + var nuevos=0; + d.messages.forEach(function(m){ if(tomar(m,true)) nuevos++; }); + if(!nuevos) return; + stopTyping(); + // No cerrar en la primera burbuja: las siguientes (max_chunks) llegan + // un poco después. El reloj de gracia corre desde la ÚLTIMA. + if(followTimer) clearTimeout(followTimer); + followTimer=setTimeout(function(){ + followTimer=null; + waiting=false; btn.disabled=false; + pararSondeo(); + }, ${DEMO_FOLLOW_MS}); + }).catch(function(){}); + } + + // Una sola sincronización, al cargar, y el envío espera a que termine. + // Así el cursor sale del servidor y un poll tardío no se traga la respuesta + // que acaba de generar el turno (la marcaría como "ya vista" sin pintarla). + var listo=new Promise(function(resolve){ + fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after=0') + .then(function(r){ return r.json(); }) + .then(function(d){ + if(d&&d.ok&&d.messages) d.messages.forEach(function(m){ tomar(m,false); }); + }) + .catch(function(){}) + .then(resolve); + }); + + function send(){ + var text=ta.value.trim(); if(!text||waiting) return; + waiting=true; btn.disabled=true; + listo.then(function(){ + add(text,'me'); ta.value=''; ta.style.height='auto'; + typing(); + fetch('/demo/send',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({sessionId:sid,text:text})}) + .then(function(r){ return r.json().catch(function(){ return {ok:r.ok}; }); }) + .then(function(d){ + if(d&&d.ok===false){ + stopTyping(); waiting=false; btn.disabled=false; + add(d.error==='limit' + ? ${JSON.stringify("Se acabó la demo 🙂 Escríbele al dueño para seguir la conversación.")} + : ${JSON.stringify("Ups, algo falló. Intenta de nuevo.")},'bot','err'); + return; + } + pararSondeo(); + if(followTimer){ clearTimeout(followTimer); followTimer=null; } + polling=setInterval(poll,1200); + if(giveUpTimer) clearTimeout(giveUpTimer); + giveUpTimer=setTimeout(function(){ + giveUpTimer=null; + if(!waiting) return; + stopTyping(); waiting=false; btn.disabled=false; + pararSondeo(); + if(followTimer){ clearTimeout(followTimer); followTimer=null; } + }, ${DEMO_GIVE_UP_MS}); + }) + .catch(function(){ stopTyping(); waiting=false; btn.disabled=false; + add(${JSON.stringify("Sin conexión. Revisa tu internet.")},'bot','err'); }); + }); + } +})();`; +} + +/** GET /demo → la página de chat, con la marca del negocio. */ +export function demoPage(c: Context<{ Bindings: Env }>) { + if (!demoEnabled(c.env)) return c.text("Not found", 404); + + const business = esc(c.env.BUSINESS_NAME || "Tu negocio"); + const bot = esc(c.env.BOT_NAME || business); + const initial = (c.env.BUSINESS_NAME || c.env.BOT_NAME || "•").trim().charAt(0).toUpperCase() || "•"; + + return c.html(` + + + +${bot} + + + +
+
${esc(initial)}
+
${bot}En línea
+
+
+
¡Hola! Soy el asistente de ${business}. ¿En qué te puedo ayudar?
+
+
+ + +
+
Demo de ${business} · atendido por IA
+ +`); +} diff --git a/src/env.ts b/src/env.ts index f67c4678..f13cb93f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -21,6 +21,9 @@ export interface Env { // re-etiqueta el dashboard, aporta el playbook del giro y sus columnas. // Ausente/desconocido → pack genérico (comportamiento actual). Ver src/niches/. BOT_NICHE?: string; + // "on" enciende el chat público de /demo. Apagado por defecto: un bot de + // producción no debe exponer un chat sin autenticar (es acceso a la llave de IA). + DEMO_MODE?: string; // LLM provider for the chat brain: "anthropic" (default) | "openai". // If unset and only OPENAI_API_KEY is present, auto-selects "openai". // (Voice transcription + embeddings always run on Cloudflare Workers AI.) diff --git a/src/index.ts b/src/index.ts index 45c61d68..0e32d687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ import { saveCapture, isLearnMode } from "./learn/mapping"; import { tokensMatch, manychatWebhookAllowed } from "./http-auth"; import { apiApp } from "./api"; import { getAgentStub } from "./agentStub"; +import { webAdapter } from "./channels/web"; +import { demoEnabled, demoOverLimit, demoPage, demoPoll, demoTurnsUsed } from "./demo"; export { SupportAgent } from "./agent"; @@ -55,6 +57,21 @@ async function routeToAgent(c: { req: { raw: Request }; env: Env; text: (t: stri } } +// Chat público de /demo. Apagado salvo DEMO_MODE=on. El cursor del poll lo +// pone el cliente con la hora del servidor (ver src/demo.ts), no con la del +// navegador. +app.get("/demo", (c) => demoPage(c)); +app.get("/demo/poll", (c) => demoPoll(c)); +app.post("/demo/send", async (c) => { + if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404); + const body = (await c.req.raw.clone().json().catch(() => ({}))) as { sessionId?: string }; + const sid = String(body.sessionId ?? "").slice(0, 64); + if (sid && demoOverLimit(await demoTurnsUsed(c.env, sid))) { + return c.json({ ok: false, error: "limit" }, 429); + } + return routeToAgent(c, webAdapter); +}); + app.post("/webhooks/telegram", (c) => routeToAgent(c, telegramAdapter)); // ManyChat — guarded by the X-Api-Key header the setup guide already asks for. // No-op until MANYCHAT_WEBHOOK_SECRET is set, so existing bots keep working. diff --git a/src/replies/sender.ts b/src/replies/sender.ts index a11f2421..379dc050 100644 --- a/src/replies/sender.ts +++ b/src/replies/sender.ts @@ -5,6 +5,7 @@ import { manychatAdapter } from "../channels/manychat"; import { twilioAdapter } from "../channels/twilio"; import { metaAdapter } from "../channels/meta"; import { whatsappAdapter } from "../channels/whatsapp"; +import { webAdapter } from "../channels/web"; const MIN_DELAY_MS = 800; const MAX_DELAY_MS = 1500; @@ -41,5 +42,6 @@ export function pickAdapter(channel: ChannelId): ChannelAdapter { if (channel === "twilio") return twilioAdapter; if (channel === "whatsapp") return whatsappAdapter; if (channel === "messenger" || channel === "instagram") return metaAdapter; + if (channel === "web") return webAdapter; throw new Error(`unknown channel: ${channel}`); } diff --git a/test/channels/web.test.ts b/test/channels/web.test.ts new file mode 100644 index 00000000..fecd044b --- /dev/null +++ b/test/channels/web.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { webAdapter } from "../../src/channels/web"; + +function req(body: unknown): Request { + return new Request("https://bot.test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("webAdapter", () => { + it("normaliza sessionId y texto", async () => { + const msg = await webAdapter.parseIncoming( + req({ sessionId: "abc-123", text: " hola ", name: "Ana" }), + {} as any, + ); + expect(msg.channel).toBe("web"); + expect(msg.channelUserId).toBe("abc-123"); + expect(msg.text).toBe("hola"); + expect(msg.displayName).toBe("Ana"); + }); + + it("rechaza un envío vacío", async () => { + await expect(webAdapter.parseIncoming(req({ sessionId: "", text: "hola" }), {} as any)) + .rejects.toThrow(/sessionId/); + }); + + it("sendReply no llama a nadie", async () => { + await expect(webAdapter.sendReply({ + channel: "web", + channelUserId: "s", + chunks: ["uno", "dos"], + }, {} as any)).resolves.toBeUndefined(); + }); +}); diff --git a/test/demo.test.ts b/test/demo.test.ts new file mode 100644 index 00000000..b876a2d0 --- /dev/null +++ b/test/demo.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("agents", () => ({ Agent: class {} })); + +import worker from "../src/index"; +import { demoClientScript, demoEnabled, demoOverLimit, DEMO_FOLLOW_MS, DEMO_GIVE_UP_MS } from "../src/demo"; +import { createTestMiniflare } from "./helpers/miniflareSetup"; +import { Db } from "../src/db/client"; +import { ConversationsRepo } from "../src/db/conversations"; +import { MessagesRepo } from "../src/db/messages"; + +const baseEnv = { + BOT_NAME: "Fer", + BUSINESS_NAME: "Barbería Atlas", + BOT_LANGUAGE: "es", + BOT_TIER: "pro", + BUFFER_SECONDS: "15", + DASHBOARD_BASE_URL: "https://test.workers.dev", +} as any; + +const on = { ...baseEnv, DEMO_MODE: "on" }; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("modo demo — interruptor", () => { + it("apagado por defecto", () => { + expect(demoEnabled(baseEnv)).toBe(false); + expect(demoEnabled({ ...baseEnv, DEMO_MODE: "off" })).toBe(false); + }); + + it("se enciende con DEMO_MODE=on, sin importar mayúsculas", () => { + expect(demoEnabled(on)).toBe(true); + expect(demoEnabled({ ...baseEnv, DEMO_MODE: "ON" })).toBe(true); + }); + + it("corta la sesión al llegar al tope de turnos", () => { + expect(demoOverLimit(39)).toBe(false); + expect(demoOverLimit(40)).toBe(true); + }); +}); + +describe("modo demo — rutas", () => { + it("/demo da 404 cuando está apagado", async () => { + const res = await worker.fetch(new Request("https://test/demo"), baseEnv, {} as any); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Not found"); + }); + + it("/demo/send da 404 cuando está apagado", async () => { + const res = await worker.fetch( + new Request("https://test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "s1", text: "hola" }), + }), + baseEnv, + {} as any, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ ok: false, error: "demo_off" }); + }); + + it("sirve la página con la marca y sin el cursor del reloj del navegador", async () => { + const res = await worker.fetch(new Request("https://test/demo"), on, {} as any); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Barbería Atlas"); + expect(html).toContain("/demo/send"); + expect(html).toContain("/demo/poll"); + expect(html).toContain("after=0"); + expect(html).not.toMatch(/since\s*=\s*Date\.now\(\)/); + expect(html).toContain(String(DEMO_FOLLOW_MS)); + expect(html).toContain(String(DEMO_GIVE_UP_MS)); + }); + + it("escapa el nombre del negocio", async () => { + const res = await worker.fetch( + new Request("https://test/demo"), + { ...on, BUSINESS_NAME: "" } as any, + {} as any, + ); + const html = await res.text(); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("/demo/poll exige sesión", async () => { + const res = await worker.fetch(new Request("https://test/demo/poll"), on, {} as any); + expect(res.status).toBe(400); + }); + + it("/demo/poll filtra por created_at del servidor y no devuelve al visitante", async () => { + const mf = await createTestMiniflare(); + const db = new Db((await mf.getD1Database("DB")) as any); + const conv = await new ConversationsRepo(db).getOrCreate("web", "sesion-1"); + const msgs = new MessagesRepo(db); + await msgs.append(conv.id, "user", "hola", { createdAt: 1000 }); + await msgs.append(conv.id, "assistant", "primera", { createdAt: 1500 }); + await msgs.append(conv.id, "assistant", "segunda", { createdAt: 3000 }); + + const env = { ...on, DB: db.d1 }; + const pedir = (after: number) => + worker.fetch(new Request(`https://test/demo/poll?session=sesion-1&after=${after}`), env, {} as any); + + const todo = await (await pedir(0)).json() as { messages: { id: string; text: string; at: number }[] }; + expect(todo.messages.map((m) => m.text)).toEqual(["primera", "segunda"]); + expect(todo.messages[0].at).toBe(1500); + expect(todo.messages[0].id).toBeTruthy(); + + const solo = await (await pedir(1500)).json() as { messages: { text: string }[] }; + expect(solo.messages.map((m) => m.text)).toEqual(["segunda"]); + + const nada = await (await pedir(3000)).json() as { messages: unknown[] }; + expect(nada.messages).toEqual([]); + }); + + it("/demo/poll devuelve lista vacía si la sesión no existe", async () => { + const mf = await createTestMiniflare(); + const env = { ...on, DB: await mf.getD1Database("DB") }; + const res = await worker.fetch( + new Request("https://test/demo/poll?session=nueva&after=0"), + env, + {} as any, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, messages: [] }); + }); + + it("rechaza el turno 41 sin llamar al agente", async () => { + const env = { + ...on, + DB: { + prepare: (sql: string) => ({ + bind: () => ({ + first: async () => (sql.includes("COUNT") ? { n: 40 } : null), + all: async () => ({ results: [] }), + }), + }), + }, + }; + const res = await worker.fetch( + new Request("https://test/demo/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "s1", text: "otra" }), + }), + env, + {} as any, + ); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ ok: false, error: "limit" }); + }); +}); + +describe("cliente de /demo", () => { + it("no arma el cursor con el reloj y no corta el sondeo en la primera burbuja", () => { + const script = demoClientScript(); + expect(script).not.toMatch(/since\s*=\s*Date\.now\(\)/); + expect(script).toContain("&after=0"); + expect(script).toContain("seen["); + const pollFn = script.slice(script.indexOf("function poll("), script.indexOf("var listo")); + // pararSondeo solo corre dentro del timeout de gracia, no al pintar la primera burbuja. + const antesDelTimeout = pollFn.slice(0, pollFn.indexOf("setTimeout")); + expect(antesDelTimeout).not.toContain("pararSondeo"); + expect(pollFn).toContain("pararSondeo()"); + expect(pollFn).toContain("followTimer"); + expect(pollFn).toContain(String(DEMO_FOLLOW_MS)); + }); + + it("pinta la respuesta aunque el reloj del visitante vaya adelantado, y la burbuja siguiente", async () => { + vi.useFakeTimers(); + const serverNow = 1_700_000_000_000; + vi.setSystemTime(serverNow + 3 * 60_000); + + const stored: { id: string; text: string; at: number }[] = [ + { id: "old", text: "historial", at: serverNow - 10_000 }, + ]; + const polls: number[] = []; + + const fetchMock = vi.fn(async (url: string) => { + const u = new URL(url, "https://test"); + if (u.pathname === "/demo/poll") { + const after = Number(u.searchParams.get("after") ?? 0); + polls.push(after); + return new Response(JSON.stringify({ + ok: true, + messages: stored.filter((m) => m.at > after), + })); + } + stored.push({ id: "a", text: "primera burbuja", at: serverNow }); + return new Response("ok", { status: 200 }); + }); + + const bubbles: string[] = []; + const nodes = new Map(); + function makeEl() { + const node: any = { + style: {}, + children: [] as any[], + className: "", + textContent: "", + innerHTML: "", + _id: "", + listeners: {} as Record, + appendChild(child: any) { + this.children.push(child); + if (this._id === "chat" && child.textContent) bubbles.push(child.textContent); + // el texto vive en .bub, no en el row + if (child.className?.startsWith?.("bub") && this.className?.includes?.("row")) { + bubbles.push(child.textContent); + } + return child; + }, + addEventListener(ev: string, fn: Function) { this.listeners[ev] = fn; }, + remove() {}, + }; + Object.defineProperty(node, "id", { + get: () => node._id, + set: (v: string) => { node._id = v; nodes.set(v, node); }, + }); + return node; + } + + const chat = makeEl(); + chat.id = "chat"; + const ta = makeEl(); + ta.id = "t"; + ta.value = ""; + const btn = makeEl(); + btn.id = "s"; + btn.disabled = false; + const document = { + getElementById: (id: string) => nodes.get(id) ?? null, + createElement: () => makeEl(), + }; + const localStorage = { + getItem: () => "sesion-prueba", + setItem: () => {}, + }; + + const runner = new Function( + "document", "localStorage", "crypto", "fetch", + "setInterval", "clearInterval", "setTimeout", "clearTimeout", + "encodeURIComponent", + demoClientScript(), + ); + runner( + document, localStorage, + { randomUUID: () => "uuid-fijo" }, + fetchMock, + globalThis.setInterval, + globalThis.clearInterval, + globalThis.setTimeout, + globalThis.clearTimeout, + encodeURIComponent, + ); + + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); + + expect(bubbles).not.toContain("historial"); + expect(polls[0]).toBe(0); + + ta.value = "hola"; + btn.listeners.click(); + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // El primer sondeo del turno pide desde el cursor del servidor (el historial), + // no desde el reloj adelantado. + await vi.advanceTimersByTimeAsync(1200); + expect(polls.some((n) => n >= serverNow + 60_000)).toBe(false); + expect(bubbles).toContain("primera burbuja"); + + stored.push({ id: "b", text: "segunda burbuja", at: serverNow + 1500 }); + await vi.advanceTimersByTimeAsync(1200); + expect(bubbles).toContain("segunda burbuja"); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index c820f9f2..fd7b3498 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -37,6 +37,7 @@ BOT_LANGUAGE = "{{BOT_LANGUAGE}}" BOT_TIER = "{{BOT_TIER}}" # 'free' | 'pro' BUFFER_SECONDS = "15" DASHBOARD_BASE_URL = "https://horizontes-bot-{{BOT_SLUG}}.workers.dev" +# DEMO_MODE = "on" # chat público en /demo. Déjalo comentado en producción. # Optional egress (ticket 7E5F40): Worker→Anthropic/Zernio 400 vacío. # AGENT_LOCATION_HINT = "wnam" # pin DOs near US (only on FIRST create)