#!/usr/bin/env node /** * ObsiGate - JSDOM integration tests for the AI tooling (#72 / AI UX). * * Covers the regressions fixed for the AI editor toolbar and BooksLM: * - custom-rewrite modal replaces window.prompt() * - Ctrl/Cmd+J inline-completion shortcut is bound exactly once * - BooksLM sends the user message (not the empty placeholder) and parses * the `{token}` SSE frames streamed by the backend * - BooksLM uses the authenticated request helper * * Usage: node tests/frontend/ai.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"); // ── JSDOM bootstrap ──────────────────────────────────────────────────────── 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.MessageEvent = w.MessageEvent; 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); // ── Test harness ─────────────────────────────────────────────────────────── let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; try { await fn(); console.log(` \u2713 ${name}`); passCount++; } catch (e) { console.log(` \u2717 ${name}`); console.log(` ${e.message}`); } } function freshToolbarContainer() { const container = document.createElement("div"); container.id = "ai-toolbar-container"; document.body.appendChild(container); return container; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ── Main ─────────────────────────────────────────────────────────────────── async function main() { // A minimal fetch mock: AI status reports one available provider and // inline-complete counts how many times it was invoked. let inlineCompleteCalls = 0; globalThis.fetch = async (url) => { const u = String(url); if (u.includes("/api/ai/status")) { return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true, model: "deepseek-chat" } } }) }; } if (u.includes("/api/config/ai-models")) { return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat"], source: "fallback" }) }; } if (u.includes("/api/ai/inline-complete")) { inlineCompleteCalls++; return { ok: true, status: 200, json: async () => ({ result: "complété", provider: "deepseek" }) }; } return { ok: true, status: 200, json: async () => ({}) }; }; const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href); const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href); const { BooksLM, MODE } = bookslmMod; const { collectOpenDocuments, documentsSignature } = bookslmMod; const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href); // ── 1. Rewrite modal resolves with the typed instruction ── await test("rewrite modal returns the typed instruction on confirm", async () => { const promise = ai._promptRewriteInstruction(); const overlay = document.querySelector(".ai-modal-overlay"); assert.ok(overlay, "modal overlay should be rendered"); const input = overlay.querySelector(".ai-modal-input"); input.value = "Rends le ton plus formel"; overlay.querySelector(".ai-modal-ok").click(); const result = await promise; assert.equal(result, "Rends le ton plus formel"); assert.equal(document.querySelector(".ai-modal-overlay"), null, "overlay removed"); }); // ── 2. Rewrite modal cancels on Escape ── await test("rewrite modal returns null on Escape", async () => { const promise = ai._promptRewriteInstruction(); document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); const result = await promise; assert.equal(result, null); }); // ── 3. Ctrl+J inline completion fires exactly once after multiple toolbar builds ── await test("Ctrl+J shortcut is bound once across toolbar re-creations", async () => { const view = { state: { selection: { main: { from: 0, to: 5 } }, doc: { toString: () => "hello", sliceString: () => "hello" }, }, dispatch: () => {}, }; inlineCompleteCalls = 0; await ai.createAIToolbar(freshToolbarContainer(), () => view); await ai.createAIToolbar(freshToolbarContainer(), () => view); document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "j", ctrlKey: true, bubbles: true })); await sleep(20); assert.equal(inlineCompleteCalls, 1, `expected 1 inline-complete call, got ${inlineCompleteCalls}`); }); // ── 4. BooksLM sends the user message and parses {token} SSE frames ── await test("BooksLM streams assistant tokens and sends the user message", async () => { const sse = 'event: message\ndata: {"token":"Bonjour le monde","provider":"deepseek","model":"deepseek-chat"}\n\n' + "event: done\ndata: {}\n\n"; let capturedBody = null; const encoder = new TextEncoder(); const chunks = [encoder.encode(sse)]; const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }), }; globalThis.fetch = async (url, opts) => { if (String(url).includes("/api/ai/bookslm/chat")) { capturedBody = JSON.parse(opts.body); return { ok: true, status: 200, body: { getReader: () => reader } }; } return { ok: true, status: 200, json: async () => ({}) }; }; const panel = document.createElement("div"); panel.innerHTML = `
`; document.body.appendChild(panel); const b = new BooksLM(); b._panel = panel; b._vault = "TestVault"; b._directory = "notes"; b._contextFiles = [{ path: "notes/a.md" }]; panel.querySelector("textarea").value = "Résume ce dossier"; await b._sendMessage(); const assistant = b._messages[b._messages.length - 1]; assert.equal(assistant.role, "assistant"); assert.equal(assistant.content, "Bonjour le monde", "assistant token should be rendered"); assert.equal(capturedBody.message, "Résume ce dossier", "current user message must be sent"); assert.equal(capturedBody.vault, "TestVault"); assert.deepEqual(capturedBody.conversation_history, [], "history excludes the current message"); panel.remove(); }); // ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ── await test("thread renders chronologically (user post above its answer)", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [ { role: "user", content: "ancien" }, { role: "assistant", content: "ancienne réponse" }, { role: "user", content: "Nouveau sujet" }, { role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" }, ]; b._renderMessages(); const roles = [...panel.querySelectorAll(".bookslm-messages > .bookslm-msg")].map((el) => el.classList.contains("user") ? "user" : "assistant"); assert.deepEqual(roles, ["user", "assistant", "user", "assistant"], "chronological order kept"); // No background bubble on the assistant answer (Notion-style full-width text). const aBubble = panel.querySelector(".bookslm-msg.assistant .bookslm-bubble"); assert.ok(aBubble.classList.contains("assistant"), "assistant bubble class"); panel.remove(); }); await test("sending anchors the latest user post to the top of the thread", async () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [ { role: "user", content: "ancien" }, { role: "assistant", content: "ancienne réponse" }, { role: "user", content: "Dernier sujet" }, ]; b._renderMessages(); let anchored = null; const users = panel.querySelectorAll(".bookslm-msg.user"); const last = users[users.length - 1]; last.scrollIntoView = (opts) => { anchored = opts; }; b._anchorLatestUser(); await sleep(10); // requestAnimationFrame shim → setTimeout(0) assert.ok(anchored, "scrollIntoView called on the latest user post"); assert.equal(anchored.block, "start"); panel.remove(); }); await test("a short answer still rolls the post up: bottom padding is grown", () => { // jsdom has no layout, so the geometry is stubbed: the post sits 500px // down the thread while only 300px of scroll room exist. Without the // padding trick the browser clamps and the post stops mid-window. const b = new BooksLM(); b._messages = [ { role: "user", content: "question" }, { role: "assistant", content: "réponse courte" }, ]; const panel = document.createElement("div"); panel.innerHTML = `
`; document.body.appendChild(panel); b._panel = panel; const raf = globalThis.requestAnimationFrame; globalThis.requestAnimationFrame = (fn) => fn(); try { // Render the messages, then fake the geometry and re-anchor. b._renderMessages(); const c = panel.querySelector(".bookslm-messages"); const post = panel.querySelector(".bookslm-msg.user"); Object.defineProperty(c, "clientHeight", { value: 400, configurable: true }); Object.defineProperty(c, "scrollHeight", { value: 700, configurable: true, writable: true }); c.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, bottom: 400, width: 400, height: 400 }); post.getBoundingClientRect = () => ({ top: 500, left: 0, right: 0, bottom: 560, width: 300, height: 60 }); b._anchorLatestUser({ instant: true }); // target (500) - max (300) = 200px of missing room. assert.equal(c.style.paddingBottom, "200px", "padding grown by the missing room"); // Plenty of room below the post → no padding at all. Object.defineProperty(c, "scrollHeight", { value: 2000, configurable: true, writable: true }); c.scrollTop = 0; b._anchorLatestUser({ instant: true }); assert.equal(c.style.paddingBottom, "", "no padding when the thread already scrolls far enough"); } finally { globalThis.requestAnimationFrame = raf; panel.remove(); } }); await test("the placeholder render keeps the anchor (no scroll reset before first token)", async () => { // Regression: the assistant placeholder used to be rendered while // `_isLoading` was still false, so it took the "restore previous // scroll" branch and cancelled the anchor — the submitted post stayed // at the bottom until the first streamed token. const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); const renders = []; const realRender = b._renderMessages.bind(b); b._renderMessages = (opts) => { renders.push(opts || {}); realRender(opts || {}); }; b._setActivity = () => {}; b._saveHistory = () => {}; b._postChat = async () => ({ ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) }, }); panel.querySelector("textarea").value = "Question ancrée"; await b._sendMessage(); // Two renders happen on send: the user post and the assistant // placeholder — both must request the anchor. assert.ok(renders.length >= 2, `expected >=2 renders, got ${renders.length}`); assert.equal(renders[0].anchor, true, "user post render anchors"); assert.equal(renders[1].anchor, true, "placeholder render anchors too"); // Both must be instant: the question has to be at the top when the // answer starts arriving, not after a scroll animation. assert.equal(renders[0].instant, true, "user post anchors instantly"); assert.equal(renders[1].instant, true, "placeholder anchors instantly"); panel.remove(); }); await test("assistant answer shows a discreet provider/model tag", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [ { role: "user", content: "Bonjour" }, { role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" }, ]; b._renderMessages(); const meta = panel.querySelector(".bookslm-msg.assistant .bookslm-msg-meta"); assert.ok(meta, "provider/model meta line rendered"); assert.equal(meta.textContent, "deepseek · deepseek-v3"); panel.remove(); }); await test("action bar: user has copy, assistant has copy + add", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [ { role: "user", content: "Question copiable" }, { role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" }, ]; b._renderMessages(); assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy"), "user copy action"); assert.ok(!panel.querySelector(".bookslm-msg.user .bookslm-msg-action-insert"), "no add on user post"); assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-copy"), "assistant copy action"); assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert"), "assistant add action"); panel.remove(); }); await test("copy action copies the raw message text to the clipboard", async () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [{ role: "user", content: "texte à copier" }]; b._renderMessages(); const copied = []; b._copyText = (t) => copied.push(t); const btn = panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy"); assert.ok(btn, "copy button present"); btn.click(); assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content"); panel.remove(); }); await test("add action inserts the answer at the editor cursor", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [{ role: "assistant", content: "Réponse à insérer" }]; b._renderMessages(); const dispatched = []; state.editorView = { state: { selection: { main: { to: 7 } } }, dispatch: (spec) => dispatched.push(spec), focus: () => {}, }; panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert").click(); assert.equal(dispatched.length, 1, "editor dispatch called"); assert.ok(dispatched[0].changes.insert.includes("Réponse à insérer")); state.editorView = null; panel.remove(); }); await test("manual wheel scroll releases the top-pinning", async () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); b._messages = [ { role: "user", content: "premier" }, { role: "assistant", content: "réponse" }, { role: "user", content: "deuxième" }, ]; b._renderMessages({ anchor: true }); assert.equal(b._pinnedTurn, true, "anchor sets the pin"); const container = panel.querySelector(".bookslm-messages"); container.dispatchEvent(new w.WheelEvent("wheel", { deltaY: -30, bubbles: true })); assert.equal(b._pinnedTurn, false, "wheel releases the pin"); panel.remove(); }); await test("empty content renders no action bar", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; b._messages = [{ role: "user", content: " " }]; b._renderMessages(); assert.equal(panel.querySelectorAll(".bookslm-msg-actions").length, 0); panel.remove(); }); await test("agent tool calls collapse into a discreet steps block", () => { const b = new BooksLM(); const trace = b._renderToolActivity([ { name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }, { name: "search", ok: true }, ]); assert.equal(trace.tagName, "DETAILS", "collapsible details element"); const summary = trace.querySelector("summary"); assert.ok(summary, "summary present"); assert.ok(summary.textContent.trim().length > 0, "summary shows the steps label"); assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside"); }); await test("_stepText uses the backend label key, falls back to tool name", () => { const b = new BooksLM(); // No locales loaded in the JSDOM harness → t() returns the key, so the // fallback path (legacy name) is what surfaces; assert it never throws // and produces a non-empty string for both shapes. const withStep = b._stepText({ name: "read_file", step: { key: "file_read", params: { value: "a.md" } } }); const legacy = b._stepText({ name: "read_file" }); assert.equal(typeof withStep, "string"); assert.equal(typeof legacy, "string"); assert.ok(legacy.length > 0); }); // ── 5. Source guards: no window.prompt() and authenticated BooksLM ── await test("ai.js no longer uses window.prompt()", async () => { const { readFileSync } = await import("node:fs"); const raw = readFileSync(path.join(JS_DIR, "ai.js"), "utf-8"); const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").split("\n").map((l) => l.replace(/\/\/.*$/, "")).join("\n"); assert.ok(!/\bprompt\s*\(/.test(code), "ai.js should not call prompt()"); assert.ok(code.includes("_promptRewriteInstruction"), "rewrite modal helper present"); }); await test("bookslm.js uses the authenticated API helper", async () => { const { readFileSync } = await import("node:fs"); const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8"); assert.ok(/import\s*\{[^}]*AuthManager[^}]*\}\s*from\s*['"]\.\/auth\.js['"]/.test(src), "AuthManager imported"); assert.ok(src.includes("data.token"), "SSE token field parsed"); }); // ── 6. Every t() key used by the AI tooling exists in FR and EN ── await test("AI/BooksLM i18n keys exist in fr.json and en.json", async () => { const { readFileSync } = await import("node:fs"); const localesDir = path.resolve(JS_DIR, "..", "locales"); const fr = JSON.parse(readFileSync(path.join(localesDir, "fr.json"), "utf-8")); const en = JSON.parse(readFileSync(path.join(localesDir, "en.json"), "utf-8")); const keys = new Set(); for (const file of ["ai.js", "bookslm.js"]) { const src = readFileSync(path.join(JS_DIR, file), "utf-8"); for (const m of src.matchAll(/\bt\(\s*['"]([a-z0-9_.]+)['"]/g)) { keys.add(m[1]); } } assert.ok(keys.size > 20, "should discover AI translation keys"); const missingFr = [...keys].filter((k) => !(k in fr)); const missingEn = [...keys].filter((k) => !(k in en)); assert.deepEqual(missingFr, [], `missing FR keys: ${missingFr.join(", ")}`); assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`); }); // ── 7. Context detection: open documents across tabs and panes ── await test("collectOpenDocuments merges tabs and panes without duplicates", () => { window.TabManager = { _activeTabId: "t1", _tabs: [ { id: "t1", vault: "V", path: "a.md" }, { id: "t2", vault: "V", path: "b.md" }, ], }; window.PaneManager = { tabManagers: { 0: { _activeTabId: "p1", _tabs: [ { id: "p1", vault: "V", path: "b.md" }, { id: "p2", vault: "W", path: "c.md" }, ], }, }, }; const docs = collectOpenDocuments(); assert.equal(docs.length, 3, "b.md must be de-duplicated"); assert.equal(docs[0].path, "a.md", "active tab first"); delete window.TabManager; delete window.PaneManager; }); await test("documentsSignature is stable", () => { const sig = documentsSignature([{ vault: "V", path: "a.md" }, { vault: "V", path: "b.md" }]); assert.equal(sig, "V/a.md\u0000V/b.md"); assert.equal(documentsSignature([]), ""); }); await test("openForCurrentContext picks documents when tabs are open", async () => { window.TabManager = { _activeTabId: "t1", _tabs: [ { id: "t1", vault: "V", path: "notes/a.md" }, { id: "t2", vault: "V", path: "notes/b.md" }, ], }; let ctxBody = null; globalThis.fetch = async (url, opts) => { const u = String(url); if (u.includes("/api/ai/status")) { return { ok: true, status: 200, json: async () => ({ configured: true, providers: {} }) }; } if (u.includes("/api/ai/bookslm/context")) { ctxBody = JSON.parse(opts.body); return { ok: true, status: 200, json: async () => ({ files: [{ path: "notes/a.md" }], total_chars: 10, file_count: 1, scope: "documents", max_total_chars: 200000 }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); await b.openForCurrentContext(); assert.equal(b._mode, "documents"); assert.equal(ctxBody.mode, "documents"); assert.deepEqual(ctxBody.context_files, ["notes/a.md", "notes/b.md"]); await sleep(10); assert.ok(b._panel.classList.contains("open")); b.close(); delete window.TabManager; }); await test("openForCurrentContext falls back to general with no document", async () => { window.TabManager = { _activeTabId: null, _tabs: [] }; const b = new BooksLM(); await b.openForCurrentContext(); assert.equal(b._mode, "general"); await sleep(10); assert.ok(b._panel.classList.contains("open")); assert.ok(!b._panel.classList.contains("hidden"), "explicit open must reveal the panel"); b.close(); delete window.TabManager; }); // ── 8. Keyboard behaviour: Enter sends, Ctrl+Enter adds a line ── await test("Enter submits and Ctrl+Enter inserts a newline", () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._isOpen = true; let sent = 0; b._sendMessage = () => { sent++; }; const textarea = b._panel.querySelector("textarea"); textarea.value = "hello"; textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); assert.equal(sent, 1, "Enter should submit"); textarea.value = "line1"; textarea.selectionStart = textarea.selectionEnd = 5; textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true })); assert.equal(sent, 1, "Ctrl+Enter must not submit"); assert.ok(textarea.value.includes("\n"), "Ctrl+Enter should insert a newline"); b._panel.remove(); }); // ── 9. General assistant action blocks ── await test("_extractActions parses obsigate-action blocks", () => { const b = new BooksLM(); const raw = 'Je crée le fichier.\n```obsigate-action\n{"action":"create_file","vault":"V","path":"a.md","content":"# A"}\n```'; const { text, actions } = b._extractActions(raw); assert.equal(actions.length, 1); assert.equal(actions[0].action, "create_file"); assert.equal(actions[0].path, "a.md"); assert.ok(!text.includes("obsigate-action"), "action block removed from display text"); }); await test("_extractActions ignores malformed blocks", () => { const b = new BooksLM(); const { text, actions } = b._extractActions("```obsigate-action\nnot json\n```"); assert.equal(actions.length, 0); assert.ok(text.length >= 0); }); // ── 10. Header ergonomics: actions always visible, picker on its own row ── await test("assistant header exposes context, actions and a picker row", () => { const b = new BooksLM(); const panel = b._render(); const actions = panel.querySelector(".bookslm-header-actions"); assert.ok(actions, "header actions container"); assert.ok(actions.querySelector(".bookslm-btn-new")); assert.ok(actions.querySelector(".bookslm-btn-export")); assert.ok(actions.querySelector(".bookslm-btn-fullscreen")); assert.ok(actions.querySelector(".bookslm-btn-close")); assert.ok(actions.querySelector(".bookslm-btn-history"), "session history button"); assert.ok(panel.querySelector(".bookslm-history-menu"), "session history menu host"); assert.ok(panel.querySelector(".bookslm-toolbar .bookslm-picker-host"), "picker on its own toolbar row"); assert.ok(!panel.querySelector(".bookslm-input-hint"), "keyboard hint removed for a cleaner composer"); panel.remove(); }); // ── 11. Formatted markdown rendering ── await test("_renderMarkdown renders headings, lists, code and tables", () => { const b = new BooksLM(); const html = b._renderMarkdown([ "# Title", "", "Some **bold** and `code` text.", "", "- one", "- two", "", "| A | B |", "| --- | --- |", "| 1 | 2 |", "", "```js", "const x = 1;", "```", ].join("\n")); assert.ok(html.includes("

Title

"), "heading rendered"); assert.ok(html.includes("bold"), "bold rendered"); assert.ok(html.includes("code"), "inline code rendered"); assert.ok(html.includes(""), "list rendered"); assert.ok(html.includes("") && html.includes("") && html.includes(""), "table rendered"); assert.ok(html.includes("language-js") && html.includes("const x = 1;"), "fenced code rendered"); }); await test("_renderMarkdown escapes model HTML output", () => { const b = new BooksLM(); const html = b._renderMarkdown(''); assert.ok(!html.includes(" { const b = new BooksLM(); const html = b._renderMarkdown("Ouvre `notes/a.md` puis `projets/2026` et [le guide](docs/guide.md)."); assert.ok(html.includes('class="bookslm-path-link"'), "path links rendered"); assert.ok(html.includes('data-path="notes/a.md"') && html.includes('data-kind="file"'), "file link classified"); assert.ok(html.includes('data-path="projets/2026"') && html.includes('data-kind="dir"'), "dir link classified"); assert.ok(html.includes('data-path="docs/guide.md"'), "markdown link linkified"); }); await test("_classifyPath treats a bare filename as a name, not a file", () => { const b = new BooksLM(); assert.equal(b._classifyPath("readme.md"), "name", "bare filename → copy"); assert.equal(b._classifyPath("notes/a.md"), "file", "dir+file → open"); assert.equal(b._classifyPath("projets/2026"), "dir", "dir path → reveal"); }); // ── 12b. Spaces in file names / paths ── await test("_looksLikePath accepts spaced names but rejects code snippets", () => { const b = new BooksLM(); assert.equal(b._looksLikePath("Ma note.md"), true, "spaced filename is a path"); assert.equal(b._looksLikePath("Mon dossier/Ma note.md"), true, "spaced path is a path"); assert.equal(b._looksLikePath("const x = 1;"), false, "code snippet rejected"); assert.equal(b._looksLikePath("npm run test"), false, "shell command rejected"); }); await test("_renderMarkdown linkifies inline code containing spaces", () => { const b = new BooksLM(); const html = b._renderMarkdown("Voir `Mon dossier/Ma note.md` pour le détail."); assert.ok(html.includes('data-path="Mon dossier/Ma note.md"'), "spaced path kept intact"); assert.ok(html.includes('data-kind="file"'), "spaced file classified as file"); }); await test("_renderMarkdown linkifies markdown links with spaces and %20", () => { const b = new BooksLM(); const spaced = b._renderMarkdown("[Ma note](Mon dossier/Ma note.md)"); assert.ok(spaced.includes('data-path="Mon dossier/Ma note.md"'), "raw spaced target kept"); const encoded = b._renderMarkdown("[Ma note](Mon%20dossier/Ma%20note.md)"); assert.ok(encoded.includes('data-path="Mon dossier/Ma note.md"'), "%20 decoded to space"); }); await test("bare spaced paths link only when present in the vault index", () => { const b = new BooksLM(); // No index → no link (prose must not become a dead link). b._pathCache = []; const noCache = b._renderMarkdown("Ouvre Mon dossier/Ma note.md pour la suite"); assert.ok(!noCache.includes("bookslm-path-link"), "no link without index"); // Indexed path → link, and the leading prose word is not swallowed. b._pathCache = [{ path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" }]; const html = b._renderMarkdown("Ouvre Mon dossier/Ma note.md pour la suite"); assert.ok(html.includes('data-path="Mon dossier/Ma note.md"'), "indexed spaced path linkified"); assert.ok(html.includes(">Mon dossier/Ma note.md"), "link text excludes leading word"); assert.ok(html.includes("Ouvre Mon") === false || !html.includes(">Ouvre Mon"), "prose word kept outside the link"); }); await test("clicking a spaced file link resolves against the index", async () => { const b = new BooksLM(); b._activeVault = () => "TestVault"; const opened = []; const revealed = []; b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; b._fetchPathsForVault = async () => ([ { path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" }, { path: "Mon dossier/Sous dossier", name: "Sous dossier", type: "directory" }, ]); await b._activatePath("Ma note.md", "file"); await b._activatePath("Sous dossier", "dir"); assert.deepEqual(opened, ["Mon dossier/Ma note.md"], "spaced filename resolves to its real path"); assert.deepEqual(revealed, ["Mon dossier/Sous dossier"], "spaced directory resolves too"); }); // ── 12c. Accents and vault-prefixed paths ── await test("_looksLikePath accepts accented names", () => { const b = new BooksLM(); assert.equal(b._looksLikePath("Café.md"), true, "accented filename is a path"); assert.equal(b._looksLikePath("Recettes/Préparation.md"), true, "accented path is a path"); assert.ok(b._renderMarkdown("Voir `Recettes/Préparation.md`.").includes('data-path="Recettes/Préparation.md"'), "accented inline code becomes a link"); }); await test("bare accented paths become links without the index", () => { const b = new BooksLM(); const html = b._renderMarkdown("Ouvre Recettes/Préparation.md puis relis."); assert.ok(html.includes('data-path="Recettes/Préparation.md"'), "accented bare path linkified"); }); await test("decomposed accents match precomposed index entries", async () => { const b = new BooksLM(); b._activeVault = () => "TestVault"; b._fetchPathsForVault = async () => ([{ path: "Recettes/Préparation.md", name: "Préparation.md", type: "file" }]); const opened = []; b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; // NFD input (e + combining acute) against an NFC index entry. const nfd = "Recettes/Pre\u0301paration.md"; await b._activatePath(nfd, "file"); assert.deepEqual(opened, ["Recettes/Préparation.md"], "NFD mention resolves to the NFC path"); }); await test("_splitVaultPrefix strips a leading vault name", () => { const b = new BooksLM(); b._vault = "TestVault"; assert.deepEqual( b._splitVaultPrefix("TestVault/Recettes/Pizza Maison.md"), { vault: "TestVault", path: "Recettes/Pizza Maison.md" }, "vault prefix split off", ); assert.deepEqual( b._splitVaultPrefix("Recettes/Pizza Maison.md"), { vault: null, path: "Recettes/Pizza Maison.md" }, "no prefix when the first segment is not a vault", ); }); await test("a vault-prefixed path opens the file in that vault", async () => { const b = new BooksLM(); b._vault = "TestVault"; b._activeVault = () => "TestVault"; b._fetchPathsForVault = async (v) => (v === "TestVault" ? [{ path: "Recettes/Pizza Maison.md", name: "Pizza Maison.md", type: "file" }] : []); const opened = []; b._openFileLink = (p, v) => { opened.push([v, p]); return Promise.resolve(); }; await b._activatePath("TestVault/Recettes/Pizza Maison.md", "file"); assert.deepEqual(opened, [["TestVault", "Recettes/Pizza Maison.md"]], "opened in the named vault"); }); await test("_renderMarkdown keeps external links external", () => { const b = new BooksLM(); const html = b._renderMarkdown("[site](https://example.com/page)"); assert.ok(html.includes('href="https://example.com/page"'), "external link kept"); assert.ok(!html.includes("bookslm-path-link"), "external link not treated as a vault path"); }); await test("_activatePath opens files, reveals dirs, copies unknown names", async () => { const b = new BooksLM(); b._activeVault = () => "TestVault"; const opened = []; const revealed = []; const copied = []; b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; b._copyLinkName = (p) => { copied.push(p); }; // Resolved file → open the canonical path (not the raw mention). b._resolveExistingPath = async () => ({ path: "notes/a.md", name: "a.md", type: "file" }); await b._activatePath("a.md", "file"); assert.deepEqual(opened, ["notes/a.md"]); // Resolved directory → reveal. b._resolveExistingPath = async () => ({ path: "projets/2026", name: "2026", type: "directory" }); await b._activatePath("projets/2026", "dir"); assert.deepEqual(revealed, ["projets/2026"]); // Unresolvable file mention → copy the name instead of a dead link. b._resolveExistingPath = async () => null; await b._activatePath("bidule.md", "file"); assert.deepEqual(copied, ["bidule.md"]); }); await test("clicking a bare-name link copies the filename to the clipboard", async () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); const copied = []; b._copyLinkName = (p) => { copied.push(p); }; const container = b._panel.querySelector(".bookslm-messages"); container.innerHTML = b._renderMarkdown("Voir `readme.md` pour tout."); const nameLink = container.querySelector('a[data-kind="name"]'); assert.ok(nameLink, "bare filename rendered as a name link"); nameLink.click(); assert.deepEqual(copied, ["readme.md"]); b._panel.remove(); }); // ── 13. Session management ── await test("sessions persist and can be reopened and deleted", () => { localStorage.clear(); const b = new BooksLM(); b._panel = document.createElement("div"); b._mode = MODE.DIRECTORY; b._vault = "VaultSessions"; b._directory = "notes"; b._loadHistory(); assert.equal(b._sessions.length, 1, "one session created on first load"); b._messages.push({ role: "user", content: "Première question" }); b._messages.push({ role: "assistant", content: "Réponse" }); b._saveHistory(); const firstId = b._currentSessionId; b.newConversation(); assert.equal(b._sessions.length, 2, "new conversation adds a session"); assert.notEqual(b._currentSessionId, firstId, "current session switched"); b._messages.push({ role: "user", content: "Deuxième" }); b._saveHistory(); b._loadSession(firstId); assert.equal(b._messages.length, 2, "previous session messages restored"); assert.equal(b._messages[0].content, "Première question"); const first = b._sessions.find((s) => s.id === firstId); assert.ok(first && first.title.includes("Première"), "session titled from first user message"); b._deleteSession(firstId); assert.equal(b._sessions.length, 1, "session deleted"); assert.ok(!b._sessions.find((s) => s.id === firstId), "deleted session gone"); }); await test("history menu lists saved sessions", () => { localStorage.clear(); const b = new BooksLM(); b._mode = MODE.DIRECTORY; b._vault = "VaultMenu"; b._directory = "d"; b._loadHistory(); b._messages.push({ role: "user", content: "Hello menu" }); b._saveHistory(); b._panel = b._render(); document.body.appendChild(b._panel); b._renderHistoryMenu(); const items = b._panel.querySelectorAll(".bookslm-history-item"); assert.equal(items.length, 1, "one session listed"); assert.ok(items[0].textContent.includes("Hello menu"), "session title shown"); b._panel.remove(); }); await test("newConversation before open defers a fresh session", async () => { localStorage.clear(); const b = new BooksLM(); b.newConversation(); assert.equal(b._pendingNewSession, true, "fresh session deferred"); await b.openContext({ mode: MODE.GENERAL }); assert.equal(b._pendingNewSession, false, "deferred session applied on open"); assert.equal(b._sessions.length, 1, "fresh session created"); b.close(); }); await test("legacy single-conversation history is migrated", () => { localStorage.clear(); const b = new BooksLM(); b._mode = MODE.GENERAL; localStorage.setItem( b._legacyHistoryKey(), JSON.stringify([{ role: "user", content: "Ancienne conversation" }]) ); b._loadHistory(); assert.equal(b._sessions.length, 1, "legacy history imported as a session"); assert.equal(b._messages[0].content, "Ancienne conversation"); }); // ── 14. Agent mode & confirmations (B5) ── await test("agent mode toggle persists and switches the endpoint", async () => { localStorage.clear(); const b = new BooksLM(); assert.equal(b._agentMode, false, "agent mode off by default"); b._toggleAgentMode(); assert.equal(b._agentMode, true); assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true"); const b2 = new BooksLM(); assert.equal(b2._agentMode, true, "persisted across instances"); let url = null; globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; }; b2._abortCtrl = null; await b2._postChat({ message: "x" }); assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent"); b2._agentMode = false; await b2._postChat({ message: "x" }); assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat"); localStorage.clear(); }); await test("header exposes the agent-mode toggle", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel; document.body.appendChild(panel); const btn = panel.querySelector(".bookslm-btn-agent"); assert.ok(btn, "agent toggle button present"); assert.equal(btn.getAttribute("aria-pressed"), "false"); btn.click(); assert.equal(btn.getAttribute("aria-pressed"), "true"); panel.remove(); localStorage.clear(); }); await test("_lineDiff marks additions and deletions", () => { const b = new BooksLM(); const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd"); const adds = diff.filter((l) => l.t === "+").map((l) => l.s); const dels = diff.filter((l) => l.t === "-").map((l) => l.s); assert.ok(dels.includes("b"), "removed line marked"); assert.ok(adds.includes("B"), "changed line marked as addition"); assert.ok(adds.includes("d"), "appended line marked"); }); await test("confirmation card resumes the agent run with confirm payload", async () => { localStorage.clear(); const encoder = new TextEncoder(); let posted = null; const sse = 'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' + 'event: message\ndata: {"token":"Fait."}\n\n' + "event: done\ndata: {}\n\n"; globalThis.fetch = async (url, opts) => { if (String(url).includes("/api/ai/bookslm/agent")) { posted = JSON.parse(opts.body); const chunks = [encoder.encode(sse)]; const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) }; return { ok: true, status: 200, body: { getReader: () => reader } }; } return { ok: true, status: 200, json: async () => ({ raw: "old content" }) }; }; const b = new BooksLM(); b._agentMode = true; b._panel = b._render(); document.body.appendChild(b._panel); const msg = { role: "assistant", content: "", sources: [], toolCalls: [], confirmation: { pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } }, messages: [{ role: "system", content: "s" }], }, payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] }, }; b._messages = [msg]; await b._applyConfirmation(msg); assert.equal(msg.confirmation, null, "confirmation resolved"); assert.ok(posted, "resumed via /agent"); assert.equal(posted.confirm.error.tool, "edit_file"); assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]); const cont = b._messages[b._messages.length - 1]; assert.equal(cont.content, "Fait."); assert.equal(cont.toolCalls.length, 1, "tool trace captured"); b._panel.remove(); localStorage.clear(); }); // ── 29. Resizable panel bounds and persistence (#81) ── await test("panel width is clamped and persisted", async () => { localStorage.clear(); const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); assert.equal(b._clampPanelWidth(100), 320, "min width enforced"); assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced"); localStorage.setItem("obsigate-bookslm-width", "640"); b._applyPanelWidth(); assert.equal(b._panel.style.width, "640px", "persisted width applied"); assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present"); b._panel.remove(); localStorage.clear(); }); // ── 30. Ad-hoc context chips (#81) ── await test("ad-hoc files and directories render as chips", async () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._addAdhocFile("notes/a.md"); b._addAdhocDir("projets/"); const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip"); assert.equal(chips.length, 2, "one chip per ad-hoc entry"); assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped"); b._removeAdhocFile("notes/a.md"); assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1); b._panel.remove(); }); // ── 31. Chat payload carries extras, images and skill (#81) ── await test("send payload includes extra context, images and skill", async () => { localStorage.clear(); const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._mode = MODE.GENERAL; b._vault = "V"; b._messages = []; b._adhocFiles = [{ path: "a.md" }]; b._adhocDirs = [{ path: "sub" }]; b._images = [{ path: "pics/x.png", name: "x.png" }]; b._activeSkill = "research"; b._panel.querySelector("textarea").value = "Analyse"; let posted = null; globalThis.fetch = async (url, opts) => { if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body); return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } }; }; await b._sendMessage(); assert.ok(posted, "chat request posted"); assert.deepEqual(posted.extra_files, ["a.md"]); assert.deepEqual(posted.extra_directories, ["sub"]); assert.deepEqual(posted.images, [{ path: "pics/x.png" }]); assert.equal(posted.skill, "research"); b._panel.remove(); localStorage.clear(); }); // ── 32. Images force the plain chat endpoint (not the agent) (#81) ── await test("images route to /chat even in agent mode", async () => { const b = new BooksLM(); b._agentMode = true; let url = ""; globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; }; await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] }); assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat"); await b._postChat({ images: [] }); assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode"); }); // ── 33. Vision gate (#81) ── await test("_modelSupportsVision reflects selected model capabilities", async () => { localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false }, })); const b = new BooksLM(); assert.equal(await b._modelSupportsVision(), false); localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true }, })); assert.equal(await b._modelSupportsVision(), true); localStorage.clear(); }); // ── 34. `/` command menu and selection (#81) ── await test("slash command menu lists skills and selects one", 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", icon: "🔎", type: "skill", description: "d" }], commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }], }), }; } 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 = "/res"; ta.selectionStart = ta.selectionEnd = 4; await b._showCommandMenu("res"); const menu = b._panel.querySelector(".bookslm-command-menu"); assert.ok(!menu.classList.contains("hidden"), "command menu shown"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1); b._selectCommand({ id: "research", label: "Recherche", type: "skill" }); assert.equal(b._activeSkill, "research"); assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered"); b._panel.remove(); }); // ── 35. `@` mention menu adds ad-hoc context / images (#81) ── await test("mention menu adds files, directories and images", async () => { localStorage.clear(); globalThis.fetch = async (url) => { if (String(url).includes("/api/tree-search")) { return { ok: true, status: 200, json: async () => ({ results: [ { path: "notes/a.md", type: "file" }, { path: "notes", type: "dir" }, ] }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._vault = "V"; await b._showMentionMenu("notes"); const menu = b._panel.querySelector(".bookslm-mention-menu"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2); b._selectMention({ id: "notes/a.md", type: "file" }); assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md")); b._selectMention({ id: "notes", type: "dir" }); assert.ok(b._adhocDirs.some((d) => d.path === "notes")); b._selectMention({ id: "pics/x.png", type: "file" }); assert.ok(b._images.some((img) => img.path === "pics/x.png")); b._panel.remove(); }); // ── 35b. `@` captures the vault for General-mode ad-hoc context (BUG-010) ── await test("mention selection stores its vault and feeds the context request", async () => { localStorage.clear(); globalThis.fetch = async (url) => { if (String(url).includes("/api/tree-search")) { return { ok: true, status: 200, json: async () => ({ results: [{ vault: "TestVault", path: "notes/a.md", type: "file" }] }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._mode = MODE.GENERAL; b._vault = null; await b._showMentionMenu("notes"); b._selectMention({ id: "notes/a.md", type: "file", vault: "TestVault" }); assert.equal(b._adhocFiles[0].vault, "TestVault", "ad-hoc entry keeps its vault"); assert.equal(b._contextVault(), "TestVault", "context vault derives from the ad-hoc entry"); let posted = null; globalThis.fetch = async (url, opts) => { if (String(url).includes("/api/ai/bookslm/context")) posted = JSON.parse(opts.body); return { ok: true, status: 200, json: async () => ({ files: [], scope: "documents" }) }; }; await b._loadContext(); assert.equal(posted.vault, "TestVault", "context request carries the vault"); assert.deepEqual(posted.extra_files, ["notes/a.md"]); b._panel.remove(); localStorage.clear(); }); // ── 36. Capability checklist rendering (#81) ── await test("capability list renders checked and unchecked flags", () => { const caps = { chat: true, embeddings: false, rerank: false, images: false, video: false, audio_speech: false, audio_transcription: false, vision: true, }; const box = ai.renderCapabilityList(caps); assert.equal(box.querySelectorAll(".ai-cap-item").length, 8); assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2); assert.ok(box.textContent.includes("☑")); assert.ok(box.textContent.includes("□")); }); // ── 37. Admin commands handled locally (#81) ── await test("admin commands are parsed without an LLM call", async () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._messages = []; let fetched = false; globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; }; const handled = b._maybeRunAdminWithArg("/model deepseek-chat"); assert.equal(handled, true); assert.equal(fetched, false, "no LLM request for admin command"); assert.ok(b._messages.length === 1, "assistant note pushed"); b._panel.remove(); }); // ── 38. Accent-insensitive `/` command filtering (BUG-007) ── await test("slash command filter is accent-insensitive", async () => { localStorage.clear(); globalThis.fetch = async (url) => { if (String(url).includes("/api/ai/skills")) { return { ok: true, status: 200, json: async () => ({ skills: [{ id: "resume", label: "Résumé", icon: "📄", 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 = "/resume"; ta.selectionStart = ta.selectionEnd = 7; b._onComposerInput(); await sleep(20); let menu = b._panel.querySelector(".bookslm-command-menu"); assert.ok(!menu.classList.contains("hidden"), "ascii query keeps the menu open"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1); ta.value = "/résumé"; ta.selectionStart = ta.selectionEnd = 7; b._onComposerInput(); await sleep(20); menu = b._panel.querySelector(".bookslm-command-menu"); assert.ok(!menu.classList.contains("hidden"), "accented query keeps the menu open"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1); b._panel.remove(); localStorage.clear(); }); // ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ── await test("mention menu accepts accents and searches all vaults without a vault", async () => { localStorage.clear(); let lastUrl = ""; globalThis.fetch = async (url) => { lastUrl = String(url); if (lastUrl.includes("/api/tree-search")) { return { ok: true, status: 200, json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._vault = null; const ta = b._panel.querySelector("textarea"); ta.value = "@café"; ta.selectionStart = ta.selectionEnd = 5; b._onComposerInput(); await sleep(20); const menu = b._panel.querySelector(".bookslm-mention-menu"); assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open"); assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1); b._panel.remove(); localStorage.clear(); }); // ── 40. Keyboard navigation in the command menu (BUG-007) ── await test("arrow keys move the active item in the command menu", async () => { localStorage.clear(); globalThis.fetch = async (url) => { if (String(url).includes("/api/ai/skills")) { return { ok: true, status: 200, json: async () => ({ skills: [ { id: "a", label: "Alpha", type: "skill", description: "d" }, { id: "b", label: "Beta", type: "skill", description: "d" }, { id: "c", label: "Gamma", 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 = 1; b._onComposerInput(); await sleep(20); const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item")); assert.equal(items().length, 3); assert.ok(items()[0].classList.contains("active"), "first item active by default"); const down = new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }); ta.dispatchEvent(down); assert.ok(down.defaultPrevented, "ArrowDown handled by the menu"); assert.ok(items()[1].classList.contains("active"), "second item active after ArrowDown"); ta.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); assert.ok(items()[0].classList.contains("active"), "back to first after ArrowUp"); b._panel.remove(); localStorage.clear(); }); // ── 41. Compact picker: model search + capability info bubble (#82) ── await test("picker model search filters and info reveals capabilities", async () => { localStorage.clear(); globalThis.fetch = async (url) => { const u = String(url); if (u.includes("/api/ai/status")) { return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true } } }) }; } if (u.includes("/api/config/ai-models")) { return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner", "vision-model"], source: "api" }) }; } if (u.includes("/api/ai/model-capabilities")) { return { ok: true, status: 200, json: async () => ({ capabilities: { chat: true, embeddings: false, rerank: false, images: false, video: false, audio_speech: false, audio_transcription: false, vision: false }, }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "deepseek" })); const picker = await ai.buildAIPickerUI(); assert.ok(picker, "picker built"); document.body.appendChild(picker); await sleep(30); const visible = Array.from(picker.children).filter((el) => !el.hidden); assert.equal(visible.length, 3, "three visible controls"); assert.ok(visible[0].classList.contains("ai-picker-caps-host"), "capability info comes first"); assert.ok(visible[1].classList.contains("ai-picker-select"), "provider menu comes second"); assert.ok(visible[2].classList.contains("ai-picker-model-combo"), "model menu comes third"); const hidden = picker.querySelector(".ai-picker-model"); assert.ok(hidden && hidden.hidden, "hidden native select kept for admin commands"); assert.ok(hidden.querySelectorAll("option").length >= 4, "native select holds default + models"); picker.querySelector(".ai-picker-model-trigger").click(); const pop = picker.querySelector(".ai-picker-model-pop"); assert.ok(!pop.classList.contains("hidden"), "model popover opens"); assert.equal(pop.querySelectorAll(".ai-picker-model-option").length, 4, "default + 3 models"); const search = picker.querySelector(".ai-picker-model-search"); search.value = "reason"; search.dispatchEvent(new w.Event("input", { bubbles: true })); const filtered = pop.querySelectorAll(".ai-picker-model-option"); assert.equal(filtered.length, 1, "search filters the list"); assert.equal(filtered[0].dataset.value, "deepseek-reasoner"); filtered[0].dispatchEvent(new w.Event("mousedown", { bubbles: true })); assert.equal(hidden.value, "deepseek-reasoner", "selection updates the native select"); const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker")); assert.equal(stored.model, "deepseek-reasoner"); await sleep(20); const info = picker.querySelector(".ai-picker-info"); assert.ok(!info.classList.contains("hidden"), "info button visible once capabilities loaded"); info.click(); const capsPop = picker.querySelector(".ai-picker-caps-pop"); assert.ok(!capsPop.classList.contains("hidden"), "capability bubble opens"); assert.equal(capsPop.querySelectorAll(".ai-cap-item").length, 8, "all capability flags listed in the bubble"); picker.remove(); localStorage.clear(); }); // ── 42. Picker handles very long model lists (OpenRouter) ── await test("picker caps long model lists and keeps options block-laid-out", async () => { localStorage.clear(); const models = Array.from({ length: 250 }, (_, i) => `vendor-${i}/model-${i}:free`); globalThis.fetch = async (url) => { const u = String(url); if (u.includes("/api/ai/status")) { return { ok: true, status: 200, json: async () => ({ configured: true, providers: { openrouter: { available: true } } }) }; } if (u.includes("/api/config/ai-models")) { return { ok: true, status: 200, json: async () => ({ models, source: "live" }) }; } if (u.includes("/api/ai/model-capabilities")) { return { ok: true, status: 200, json: async () => ({ capabilities: { chat: true, vision: true } }) }; } return { ok: true, status: 200, json: async () => ({}) }; }; localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "openrouter" })); const picker = await ai.buildAIPickerUI(); document.body.appendChild(picker); await sleep(30); picker.querySelector(".ai-picker-model-trigger").click(); const options = picker.querySelectorAll(".ai-picker-model-option"); assert.equal(options.length, 200, "rendered options are capped"); assert.ok(picker.querySelector(".ai-picker-model-more"), "a 'more results' hint is shown"); assert.ok(options[0].style.display === "block", "options are block-laid-out even without CSS"); picker.remove(); localStorage.clear(); }); // ── 42c. BUG-043: the provider list follows the project AI configuration ── const providerOptions = (root) => Array.from(root.querySelectorAll(".ai-picker-select option")) .map((o) => o.value) .filter(Boolean); const providerStatusMock = (getProviders, models = ["deepseek-chat"]) => async (url) => { const u = String(url); if (u.includes("/api/ai/status")) { return { ok: true, status: 200, json: async () => ({ configured: true, providers: getProviders() }) }; } if (u.includes("/api/config/ai-models")) { return { ok: true, status: 200, json: async () => ({ models, source: "api" }) }; } return { ok: true, status: 200, json: async () => ({}) }; }; await test("assistant sidebar lists a provider added or removed in the project config", async () => { localStorage.clear(); let providers = { deepseek: { available: true, model: "deepseek-chat" } }; globalThis.fetch = providerStatusMock(() => providers); const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); await sleep(30); const host = b._panel.querySelector(".bookslm-picker-host"); assert.ok(host, "picker host lives in the toolbar"); assert.deepEqual(providerOptions(host), ["deepseek"], "only the configured provider is listed"); // Admin saves a second provider key in the project config. providers = { deepseek: { available: true, model: "deepseek-chat" }, nvidia: { available: true, model: "meta/llama-3.1-405b-instruct" }, }; await ai.refreshAIPickers(); await sleep(10); assert.deepEqual(providerOptions(host), ["deepseek", "nvidia"], "added provider shows up without a reload"); assert.ok(host.querySelector(".ai-picker"), "picker rebuilt inside its host slot"); assert.equal(b._panel.querySelectorAll(".ai-picker").length, 1, "no duplicate picker mount"); // Admin deletes the key again. providers = { deepseek: { available: true, model: "deepseek-chat" } }; await ai.refreshAIPickers(); await sleep(10); assert.deepEqual(providerOptions(host), ["deepseek"], "removed provider disappears"); b._panel.remove(); localStorage.clear(); }); await test("refreshAIPickers mounts the first provider into an empty slot", async () => { localStorage.clear(); let providers = {}; globalThis.fetch = providerStatusMock(() => providers); const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); await sleep(30); const host = b._panel.querySelector(".bookslm-picker-host"); assert.equal(host.querySelector(".ai-picker"), null, "no picker without a configured provider"); providers = { deepseek: { available: true, model: "deepseek-chat" } }; await ai.refreshAIPickers(); await sleep(10); assert.ok(host.querySelector(".ai-picker"), "picker mounted into the kept slot"); assert.deepEqual(providerOptions(host), ["deepseek"]); b._panel.remove(); localStorage.clear(); }); await test("a provider whose key was deleted is dropped from the stored selection", async () => { localStorage.clear(); globalThis.fetch = providerStatusMock(() => ({ deepseek: { available: true } })); localStorage.setItem( "obsigate_ai_picker", JSON.stringify({ provider: "nvidia", model: "meta/llama-3.1-405b-instruct" }) ); const slot = document.createElement("span"); slot.className = "ai-picker-slot"; document.body.appendChild(slot); slot.replaceChildren(await ai.buildAIPickerUI()); await sleep(20); assert.equal(slot.querySelector(".ai-picker-select").value, "", "provider falls back to the default"); assert.equal(slot.querySelector(".ai-picker-model").value, "", "no stale model kept"); const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker")); assert.equal(stored.provider, null, "unconfigured provider cleared from storage"); assert.equal(stored.model, null, "its model cleared from storage"); slot.remove(); localStorage.clear(); }); await test("config save/delete rebuilds the assistant provider picker", async () => { const { readFileSync } = await import("node:fs"); const src = readFileSync(path.join(JS_DIR, "config.js"), "utf-8"); const save = src.slice(src.indexOf("async function saveAIKeys()"), src.indexOf("async function deleteAIKey(")); const del = src.slice(src.indexOf("async function deleteAIKey("), src.indexOf("async function testAIKeys(")); assert.ok(save.includes("refreshAIPickers()"), "saving a provider key refreshes the picker"); assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker"); }); // ── 42b. Assistant activity indicator (#82) ── await test("activity indicator reflects the request workflow", () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); const el = b._panel.querySelector(".bookslm-activity"); assert.ok(el, "activity element present"); assert.ok(el.classList.contains("hidden"), "hidden initially"); b._setActivity("working", "Envoi…"); assert.ok(!el.classList.contains("hidden"), "shown while working"); assert.ok(el.classList.contains("working")); assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…"); b._setActivity("error", "Échec"); assert.ok(el.classList.contains("error")); b._setActivity("idle"); assert.ok(el.classList.contains("hidden"), "hidden when idle"); b._panel.remove(); }); await test("mention menu falls back to an available vault", async () => { localStorage.clear(); const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href); const prev = stateMod.state.allVaults; stateMod.state.allVaults = [{ name: "FallbackVault" }]; let lastUrl = ""; globalThis.fetch = async (url) => { lastUrl = String(url); if (lastUrl.includes("/files")) { return { ok: true, status: 200, json: async () => ({ files: [{ path: "a.md" }] }) }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._mode = MODE.GENERAL; b._vault = null; await b._showMentionMenu(""); const menu = b._panel.querySelector(".bookslm-mention-menu"); assert.ok(!menu.classList.contains("hidden"), "empty-query menu is shown"); assert.ok(lastUrl.includes("FallbackVault"), "uses the fallback vault"); stateMod.state.allVaults = prev; b._panel.remove(); localStorage.clear(); }); await test("active vault falls back to the first available vault", async () => { const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href); const prevVaults = stateMod.state.allVaults; const prevSelected = stateMod.state.selectedContextVault; stateMod.state.allVaults = [{ name: "V1" }, { name: "V2" }]; stateMod.state.selectedContextVault = "all"; const b = new BooksLM(); b._vault = null; assert.equal(b._activeVault(), "V1", "first vault used for links/@ browsing"); stateMod.state.selectedContextVault = "V2"; assert.equal(b._activeVault(), "V2", "selected context vault takes precedence"); stateMod.state.allVaults = prevVaults; stateMod.state.selectedContextVault = prevSelected; }); await test("menu navigation works from the panel, not only the textarea", async () => { localStorage.clear(); globalThis.fetch = async (url) => { if (String(url).includes("/api/ai/skills")) { return { ok: true, status: 200, json: async () => ({ skills: [ { id: "a", label: "Alpha", type: "skill", description: "d" }, { id: "b", label: "Beta", 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 = 1; b._onComposerInput(); await sleep(20); const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item")); assert.equal(items().length, 2); // Dispatch on the panel itself (focus elsewhere) — the capture handler must // still navigate the menu. b._panel.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })); assert.ok(items()[1].classList.contains("active"), "ArrowDown from the panel moves the highlight"); b._panel.remove(); localStorage.clear(); }); // ── 42c. Fast `@` menu: client-side filtering of the prefetched path index ── await test("mention menu filters the prefetched path index instantly", async () => { localStorage.clear(); const calls = []; globalThis.fetch = async (url) => { const u = String(url); calls.push(u); if (u.includes("/paths")) { return { ok: true, status: 200, json: async () => ({ vault: "V", count: 3, results: [ { vault: "V", path: "notes/a.md", name: "a.md", type: "file" }, { vault: "V", path: "notes/b.md", name: "b.md", type: "file" }, { vault: "V", path: "projects", name: "projects", type: "directory" }, ] }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); b._vault = "V"; await b._showMentionMenu("notes"); const menu = b._panel.querySelector(".bookslm-mention-menu"); assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2, "client-side filter keeps notes/*"); assert.ok(!calls.some((u) => u.includes("/api/tree-search")), "no server search once the path cache is warm"); b._panel.remove(); localStorage.clear(); }); await test("vault root context menu exposes BooksLM", async () => { const { readFileSync } = await import("node:fs"); const src = readFileSync(path.join(JS_DIR, "ui.js"), "utf-8"); const vaultBranch = src.slice( src.indexOf("if (type === 'vault')"), src.indexOf("} else if (type === 'directory')") ); assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu"); }); // ── BUG-034: the access token is not persisted in sessionStorage ── await test("auth token kept in memory, not in sessionStorage", async () => { const authMod = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href); const { AuthManager } = authMod; sessionStorage.clear(); AuthManager.saveToken({ access_token: "secret-token", expires_in: 3600, user: { username: "u", role: "user" }, }); assert.equal(sessionStorage.getItem("obsigate_access_token"), null, "token must not be stored"); assert.equal(AuthManager.getToken(), "secret-token", "token available in memory"); assert.ok(AuthManager.hasSession(), "session detected"); AuthManager.clearSession(); assert.equal(AuthManager.getToken(), null, "session cleared"); }); // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { process.exit(1); }} main().catch((e) => { console.error(e); process.exit(1); });
A1