#!/usr/bin/env node /** * ObsiGate - JSDOM tests for the AI history sidebar filter (#98). * * Covers the client-side search over the AI conversation history rendered in * the left sidebar "Historique IA" tab: * - sessions are loaded and rendered through `loadAISessionList()` * - `filterAIHistory()` narrows the list by title / preview / directory / * context / mode label, case- and accent-insensitively * - an empty match shows a "no match" hint instead of the empty state * * Usage: node tests/frontend/ai-sidebar.test.mjs */ import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; import { fileURLToPath, pathToFileURL } from "node:url"; import path from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); 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.Element = w.Element; globalThis.Node = w.Node; globalThis.Event = w.Event; globalThis.CustomEvent = w.CustomEvent; globalThis.KeyboardEvent = w.KeyboardEvent; globalThis.localStorage = w.localStorage; globalThis.sessionStorage = w.sessionStorage; Object.defineProperty(globalThis, "navigator", { value: w.navigator, configurable: true, writable: true, }); globalThis.MutationObserver = w.MutationObserver; globalThis.getComputedStyle = w.getComputedStyle.bind(w); globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); globalThis.cancelAnimationFrame = (id) => clearTimeout(id); let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; try { await fn(); passCount++; console.log(` ✓ ${name}`); } catch (err) { console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } const SESSIONS = [ { id: "s1", title: "Pizza maison", mode: "general", updatedAt: 1, preview: "La pâte à pizza se lève toute la nuit" }, { id: "s2", title: "Recette café au lait", mode: "directory", directory: "Recettes", updatedAt: 2 }, { id: "s3", title: "Plan du cours", mode: "documents", context: "documents-books", updatedAt: 3 }, ]; globalThis.fetch = async (url) => { if (String(url).includes("/api/ai/bookslm/history")) { return { ok: true, status: 200, json: async () => ({ sessions: SESSIONS }) }; } return { ok: true, status: 200, json: async () => ({}) }; }; const mod = await import( pathToFileURL(path.join(JS_DIR, "config.js")).href ); const { loadAISessionList, filterAIHistory } = mod; const listEl = () => document.getElementById("ai-history-list"); const items = () => Array.from(listEl().querySelectorAll(".recent-item")); console.log("AI history sidebar filter (#98) — config.js"); await test("loadAISessionList renders every session", async () => { await loadAISessionList(); assert.equal(items().length, 3, "three sessions rendered"); }); await test("filter narrows by title, case-insensitively", async () => { filterAIHistory("pizza"); assert.equal(items().length, 1, "one session matches 'pizza'"); filterAIHistory("PLAN"); assert.equal(items().length, 1, "case-insensitive match on 'PLAN'"); }); await test("filter matches accents-insensitively", async () => { filterAIHistory("cafe"); assert.equal(items().length, 1, "'cafe' matches 'café au lait'"); const titles = items().map((el) => el.textContent); assert.ok(titles.some((t) => t.includes("café")), "the café session is the one kept"); }); await test("filter matches the preview and the directory", async () => { filterAIHistory("pâte"); assert.equal(items().length, 1, "'pâte' matches the preview text"); filterAIHistory("recettes"); assert.equal(items().length, 1, "'recettes' matches the session directory"); }); await test("clearing the query restores the full list", async () => { filterAIHistory(""); assert.equal(items().length, 3, "no query keeps everything"); }); await test("an unmatched query shows a no-match hint, not the empty state", async () => { filterAIHistory("zzz-inexistant"); assert.equal(items().length, 0, "no item left"); assert.ok(listEl().querySelector(".sidebar-filter-empty"), "a 'no match' hint is rendered in the list box"); const emptyEl = document.getElementById("ai-history-empty"); assert.ok(emptyEl.classList.contains("hidden") === false, "empty state is also visible"); }); // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { process.exit(1); }