#!/usr/bin/env node /** * ObsiGate — JSDOM tests for the per-file chat panel (#169): * `frontend/js/filechat.js`. * * Covers: * - `renderChatMessages()`: chronological render + empty state + escaping * - `onChatMessage()`: live append when open on the same file, dedupe by id, * no duplicate append when the panel shows another file * * Usage: node tests/frontend/filechat.test.mjs */ import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; import { readFileSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const dom = new JSDOM( `
`, { url: "http://localhost/", pretendToBeVisual: true }, ); const w = dom.window; globalThis.window = w; globalThis.document = w.document; globalThis.HTMLElement = w.HTMLElement; globalThis.CSS = w.CSS || { escape: (s) => s }; globalThis.localStorage = w.localStorage; globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16)); globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout; globalThis.sessionStorage = w.sessionStorage; const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales"); // Route fetch: locales from disk, auth 401, API chat endpoints from a stub map. const apiStubs = new Map(); globalThis.fetch = (url) => { const u = String(url); if (u.includes("/static/locales/")) { const name = u.split("/").pop(); try { const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8"); return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) }); } catch (_) { return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); } } if (u.includes("/api/auth/me")) { return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) }); } if (apiStubs.has(u)) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) }); } return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) }); }; const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); await initI18n(); const mod = await import("../../frontend/js/filechat.js"); const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, } = mod; let pass = 0; const cases = []; function test(name, fn) { cases.push([name, fn]); } function makeList() { const list = document.createElement("div"); list.className = "file-chat-list"; return list; } test("renderChatMessages: empty/missing shows the empty state", async () => { const list = makeList(); renderChatMessages(list, [], "bruno"); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0); assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true); renderChatMessages(list, undefined, "bruno"); assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true); }); test("renderChatMessages: renders chronologically with mine class", async () => { const list = makeList(); renderChatMessages( list, [ { id: "1", user: "alice", text: "bonjour", ts: 1700000000 }, { id: "2", user: "bruno", text: "salut !", ts: 1700000060 }, ], "bruno", ); const msgs = list.querySelectorAll(".file-chat-msg"); assert.strictEqual(msgs.length, 2); assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice"); assert.strictEqual(msgs[0].classList.contains("mine"), false); assert.strictEqual(msgs[1].classList.contains("mine"), true); assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !"); }); test("renderChatMessages: message text is text (no HTML injection)", async () => { const list = makeList(); renderChatMessages(list, [{ id: "1", user: "", text: "x", ts: 1 }], "u"); assert.strictEqual(list.querySelector(".file-chat-body").innerHTML, "<b>x</b>"); assert.strictEqual(list.querySelectorAll("b").length, 0); }); test("onChatMessage: appends live message to the open panel", async () => { await openFileChat("V", "note.md"); const panel = document.getElementById("file-chat-panel"); assert.strictEqual(panel.classList.contains("hidden"), false); // Seed an empty state so we can assert its removal const list = panel.querySelector(".file-chat-list"); renderChatMessages(list, [], "bruno"); onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } }); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); assert.strictEqual(list.querySelector(".file-chat-empty"), null); // Same id again → deduped onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } }); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); closeFileChat(); assert.strictEqual(document.getElementById("file-chat-panel").classList.contains("hidden"), true); }); test("onChatMessage: ignores events for another file (no cross-file append)", async () => { await openFileChat("V", "note.md"); const panel = document.getElementById("file-chat-panel"); const list = panel.querySelector(".file-chat-list"); renderChatMessages(list, [], "bruno"); onChatMessage({ vault: "V", path: "OTHER.md", message: { id: "x", user: "alice", text: "no", ts: 3 } }); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0); closeFileChat(); }); test("toggleFileChat: opens then closes on the same file", async () => { await toggleFileChat("V", "t.md"); let panel = document.getElementById("file-chat-panel"); assert.strictEqual(panel.classList.contains("hidden"), false); await toggleFileChat("V", "t.md"); panel = document.getElementById("file-chat-panel"); assert.strictEqual(panel.classList.contains("hidden"), true); closeFileChat(); }); test("#190 A — followFileChat re-targets the open panel", async () => { await openFileChat("V", "one.md", "Doc Un"); const panel = document.getElementById("file-chat-panel"); assert.strictEqual(panel.classList.contains("hidden"), false); followFileChat("V", "two.md", "Doc Deux"); await new Promise((r) => setTimeout(r, 20)); const target = panel.querySelector(".file-chat-target"); assert.strictEqual(target.textContent, "Doc Deux"); assert.strictEqual(target.title, "two.md"); closeFileChat(); followFileChat("V", "three.md", "Doc Trois"); await new Promise((r) => setTimeout(r, 20)); assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux"); }); test("#190 B — openSidebarChat renders the general history", async () => { await openSidebarChat(); const list = document.getElementById("sidebar-panel-chat-list"); assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true); }); test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => { const list = document.getElementById("sidebar-panel-chat-list"); onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } }); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde"); onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } }); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); }); test("#190 C — filterChatMessages narrows by text/user", async () => { onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } }); onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } }); const list = document.getElementById("sidebar-panel-chat-list"); filterChatMessages("pizza"); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza"); filterChatMessages("bob"); // la recherche couvre aussi l'auteur assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1); filterChatMessages(""); assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3); }); test("#190 — URL rendered as a link, surrounding text stays plain", async () => { const list = document.getElementById("sidebar-panel-chat-list"); onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } }); const msg = list.querySelector('[data-id="u1"]'); const a = msg.querySelector("a.file-chat-link"); assert.strictEqual(a.href, "https://example.com/doc"); assert.strictEqual(a.target, "_blank"); assert.ok(msg.textContent.includes("du texte")); }); // Sequential execution: each case depends on panel state left by the previous. for (const [name, fn] of cases) { try { await fn(); pass++; console.log(` ✓ ${name}`); } catch (err) { console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } console.log(`\n${pass} passed`);