#!/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; // ── 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(); }); // ── 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 shown"); 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("_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("clicking a path link opens a file or reveals a directory", () => { const b = new BooksLM(); b._panel = b._render(); document.body.appendChild(b._panel); const opened = []; const revealed = []; b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; const container = b._panel.querySelector(".bookslm-messages"); container.innerHTML = b._renderMarkdown("`notes/a.md` et `dossier/sous-dossier`"); const fileLink = container.querySelector('a[data-kind="file"]'); const dirLink = container.querySelector('a[data-kind="dir"]'); assert.ok(fileLink, "file link rendered"); assert.ok(dirLink, "dir link rendered"); fileLink.click(); dirLink.click(); assert.deepEqual(opened, ["notes/a.md"]); assert.deepEqual(revealed, ["dossier/sous-dossier"]); 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(); }); // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { process.exit(1); }} main().catch((e) => { console.error(e); process.exit(1); });
A1