#!/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(); const fetchCalls = []; // #110 — journal des appels {url, method} globalThis.fetch = (url, opts = {}) => { const u = String(url); fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() }); 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, onChatRead, _deleteMessage, } = 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); }); // --- #192 : accusé de réception + boîte de saisie auto-agrandie -------------- test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {}); let st = list.querySelector(".file-chat-status"); assert.strictEqual(st.textContent, "✓"); assert.strictEqual(st.classList.contains("read"), false); // MY OWN read timestamp must never count as "the peer read it" renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 }); assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓"); renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 }); st = list.querySelector(".file-chat-status"); assert.strictEqual(st.textContent, "✓✓"); assert.strictEqual(st.classList.contains("read"), true); // messages of others carry no receipt renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {}); assert.strictEqual(list.querySelector(".file-chat-status"), null); }); test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => { await openSidebarChat(); const list = document.getElementById("sidebar-panel-chat-list"); renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {}); assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓"); const container = document.getElementById("toast-container"); const before = container.children.length; onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } }); assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓"); assert.ok(container.children.length > before, "notification de lecture affichée"); // notre propre écho ne notifie pas const same = container.children.length; onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } }); assert.strictEqual(container.children.length, same); }); test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => { assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA"); await openFileChat("V", "grow.md"); const panel = document.getElementById("file-chat-panel"); const input = panel.querySelector(".file-chat-input"); assert.strictEqual(input.tagName, "TEXTAREA"); assert.strictEqual(input.rows, 1); input.value = "ligne1\nligne2"; input.dispatchEvent(new w.Event("input", { bubbles: true })); assert.ok(input.style.height, "hauteur pilotée en JS"); let prevented = false; const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } }); input.value = ""; // rien à envoyer : on ne teste que le geste input.dispatchEvent(ev); assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté"); const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }); let nlPrevented = false; Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } }); input.dispatchEvent(nl); assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne"); closeFileChat(); }); // --- #110 : boutons discrets, image en onglet, point non-lu, delete différé - test("#110 — bouton supprimer visible (pas opacity:0)", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat"); const del = list.querySelector(".file-chat-del"); assert.ok(del, "bouton suppression présent"); assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible"); }); test("#110 — image : boutons icônes discrets sous l'image, à droite", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "img1", user: "alice", text: "", ts: 100, attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" }, }], "bruno"); const container = list.querySelector(".file-chat-image-container"); assert.ok(container, "conteneur image présent"); const actions = container.querySelector(".file-chat-image-actions"); assert.ok(actions, "rangée de boutons sous l'image"); assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent"); assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent"); actions.querySelectorAll("button").forEach((b) => { assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)"); assert.ok(b.getAttribute("aria-label"), "aria-label présent"); }); }); test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => { const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href); const seen = []; const orig = ui.TabManager.openChatImage; ui.TabManager.openChatImage = (att) => seen.push(att); try { const list = document.createElement("div"); const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" }; renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno"); list.querySelector(".file-chat-image").click(); assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image"); assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise"); list.querySelector(".file-chat-open").click(); assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet"); } finally { ui.TabManager.openChatImage = orig; } }); test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => { const tab = document.getElementById("sidebar-tab-chat"); assert.ok(tab, "onglet chat présent"); localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 })); refreshUnreadBadge(); assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée"); localStorage.setItem("obsigate-chat-unread", JSON.stringify({})); refreshUnreadBadge(); assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu"); }); test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat"); const before = fetchCalls.length; list.querySelector(".file-chat-del").click(); assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »"); assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent"); assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre"); list.querySelector(".file-chat-undo").click(); assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré"); assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée"); assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE"); }); test("#110 — notification OS quand l'app est en arrière-plan", () => { const fired = []; class FakeNotification { constructor(title, opts) { fired.push({ title, opts }); } close() {} } FakeNotification.permission = "granted"; window.Notification = FakeNotification; Object.defineProperty(document, "hidden", { value: true, configurable: true }); try { const panel = document.getElementById("sidebar-panel-chat"); panel.classList.remove("active"); onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } }); assert.strictEqual(fired.length, 1, "notification affichée"); assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification"); } finally { Object.defineProperty(document, "hidden", { value: false, configurable: true }); window.Notification = { permission: "denied" }; } }); // --- #193 : un post est rendu comme un document (markdown + code coloré) ----- test("#193 — post avec html rendu : markdown + bloc de code", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400, html: '

gras

print(1)
', }], "bruno"); const body = list.querySelector("[data-id='md1'] .file-chat-body"); assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps"); assert.ok(body.querySelector(".md-content"), "conteneur md-content"); assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)"); assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé"); }); test("#193 — sans html, repli texte brut + URL cliquable", () => { const list = document.createElement("div"); renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno"); const body = list.querySelector("[data-id='md2'] .file-chat-body"); assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown"); assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable"); assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact"); }); // 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`);