#!/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 } = 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: "