#!/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(); // #191 — the delete right needs a logged-in identity const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href); AuthManager.getUser = () => ({ username: "bruno", role: "user" }); const mod = await import("../../frontend/js/filechat.js"); const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, onChatDeleted, } = 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")); }); test("#191 — preview card rendered with site/title/description", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "p1", user: "alice", text: "voir https://example.com", ts: 40, preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" }, }], "bruno"); const card = list.querySelector("a.file-chat-preview"); assert.ok(card, "carte preview présente"); assert.strictEqual(card.href, "https://example.com/"); assert.strictEqual(card.target, "_blank"); assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site"); assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc"); assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com"); renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno"); assert.strictEqual(list.querySelector("a.file-chat-preview"), null); }); test("#191 — delete button hidden without delUrl, shown for own post", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno"); assert.strictEqual(list.querySelector(".file-chat-del"), null); renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat"); assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible"); renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat"); assert.strictEqual(list.querySelector(".file-chat-del"), null); }); test("#191 — onChatDeleted removes the message from any list", async () => { await openSidebarChat(); const list = document.getElementById("sidebar-panel-chat-list"); onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } }); assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true); onChatDeleted({ vault: "__global__", path: "general", id: "del1" }); assert.strictEqual(list.querySelector('[data-id="del1"]'), null); }); test("#191 — unread counters accumulate then reset on open", async () => { localStorage.removeItem("obsigate-chat-unread"); const panel = document.getElementById("sidebar-panel-chat"); panel.classList.remove("active"); onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } }); onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } }); let u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); assert.strictEqual(u.general, 2); refreshUnreadBadge(); assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2"); panel.classList.add("active"); await openSidebarChat(); u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); assert.strictEqual(u.general, 0); assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null); }); test("#191 — DM routed by pair path, isolated from general", async () => { const list = document.getElementById("sidebar-panel-chat-list"); const before = list.querySelectorAll(".file-chat-msg").length; onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } }); const u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); assert.strictEqual(u["dm:alice"], 1); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before); }); // 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`);