feat: filtre de recherche dans la sidebar Historique IA (#98) + menus '@' et '/' depuis le menu '+' (BUG-048)
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m19s
CI / build (push) Successful in 54s
CI / e2e (push) Successful in 10m44s

This commit is contained in:
2026-09-16 16:11:54 -04:00
parent c9e3240ae2
commit 8e2b6203a1
19 changed files with 376 additions and 18 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/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(
`<!DOCTYPE html>
<html><body>
<aside class="sidebar">
<input id="sidebar-filter-input" value="" />
<button id="sidebar-filter-case-btn"></button>
<button id="sidebar-filter-clear-btn"></button>
<div id="ai-history-list" class="recent-list"></div>
<div id="ai-history-empty" class="recent-empty hidden">
<i data-lucide="messages-square"></i><span>Aucune conversation</span>
</div>
</aside>
</body></html>`,
{ 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);
}
+90
View File
@@ -1802,6 +1802,96 @@ async function main() {
assert.equal(AuthManager.getToken(), null, "session cleared");
});
// ── BUG-048: the "+" panel menus must stay open (contexts @ / skills /) ──
await test("ext menu opens the @ mention menu and keeps it visible", async () => {
localStorage.clear();
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prevVaults = stateMod.state.allVaults;
stateMod.state.allVaults = [{ name: "V" }];
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
commands: [],
}),
};
}
if (u.includes("/paths")) {
return {
ok: true, status: 200,
json: async () => ({ vault: "V", count: 1, results: [
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
] }),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = ta.selectionStart = ta.selectionEnd = 0;
b._toggleExtMenu();
const mentionMenu = b._panel.querySelector(".bookslm-mention-menu");
const contextBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="context"]');
assert.ok(contextBtn, "Contextes entry present in the + panel");
contextBtn.click();
await sleep(20);
assert.ok(!mentionMenu.classList.contains("hidden"),
"mention menu stays open after picking Contextes (BUG-048)");
assert.ok(mentionMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
"mention menu lists entries");
b._panel.remove();
localStorage.clear();
stateMod.state.allVaults = prevVaults;
});
await test("ext menu opens the / command menu and keeps it visible", async () => {
localStorage.clear();
globalThis.fetch = async (url) => {
if (String(url).includes("/api/ai/skills")) {
return {
ok: true, status: 200,
json: async () => ({
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
commands: [],
}),
};
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = ta.selectionStart = ta.selectionEnd = 0;
b._toggleExtMenu();
const commandMenu = b._panel.querySelector(".bookslm-command-menu");
const skillBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="skill"]');
assert.ok(skillBtn, "Skills entry present in the + panel");
skillBtn.click();
await sleep(20);
assert.ok(!commandMenu.classList.contains("hidden"),
"command menu stays open after picking Skills (BUG-048)");
assert.ok(commandMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
"command menu lists skills");
b._panel.remove();
localStorage.clear();
});
await test("the + button carries a '+' icon", async () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const plus = b._panel.querySelector(".bookslm-btn-plus i");
assert.ok(plus, "the + button shows an icon");
assert.equal(plus.getAttribute("data-lucide"), "plus", "the + button icon is a plus");
b._panel.remove();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {