- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
`resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
`chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
(6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
`docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
163 lines
6.4 KiB
JavaScript
163 lines
6.4 KiB
JavaScript
#!/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(
|
|
`<!DOCTYPE html><html><body><div id="toast-container"></div></body></html>`,
|
|
{ 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: "<img>", text: "<b>x</b>", 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();
|
|
});
|
|
|
|
// 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`);
|