Files
ObsiGate/tests/frontend/ai.test.mjs
T
bruno c9e3240ae2
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s
feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
2026-09-16 14:56:45 -04:00

1815 lines
79 KiB
JavaScript

#!/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(
`<!DOCTYPE html>
<html>
<body>
<div id="editor-modal" class="active"></div>
<div id="ai-toolbar-container"></div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.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 = `
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<textarea></textarea>
<button class="bookslm-btn-send"></button>`;
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();
});
// ── 4a-bis. BUG-046: readable errors + payload carry-over on confirmation resume ──
await test("_responseError flattens FastAPI 422 array details (no [object Object])", async () => {
const b = new BooksLM();
const resp422 = {
status: 422,
json: async () => ({ detail: [{ type: "missing", loc: ["body", "message"], msg: "Field required", input: {} }] }),
};
const err422 = await b._responseError(resp422);
assert.equal(err422.message, "Field required", `got "${err422.message}"`);
assert.ok(!err422.message.includes("[object Object]"), "must never stringify to [object Object]");
const respStr = { status: 400, json: async () => ({ detail: "vault invalide" }) };
assert.equal((await b._responseError(respStr)).message, "vault invalide");
const respEmpty = { status: 500, json: async () => { throw new Error("no body"); } };
assert.equal((await b._responseError(respEmpty)).message, "HTTP 500");
});
await test("confirmation resume keeps the payload and formats errors (BUG-046)", async () => {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
const apply = src.slice(src.indexOf("async _applyConfirmation"));
assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/,
"continuation must inherit the original request payload (no null payload)");
assert.match(apply, /await this\._responseError\(resp\)/,
"resume path must format non-OK responses via _responseError");
});
// ── 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 = `<div class="bookslm-messages"></div>`;
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 msg = {};
const trace = b._renderToolActivity([
{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } },
{ name: "search", ok: true },
], msg, false);
assert.equal(trace.tagName, "DETAILS", "collapsible details element");
const summary = trace.querySelector("summary");
assert.ok(summary, "summary present");
const label = summary.querySelector(".bookslm-steps-label");
assert.ok(label && label.textContent.length > 0, "summary shows the steps label");
assert.ok(summary.querySelector(".bookslm-chevron"), "chevron after the label");
assert.equal(summary.textContent.includes(">"), false, "no legacy '> ' prefix");
assert.equal(summary.querySelector(".bookslm-steps-dots"), null, "no indicator when idle");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
});
await test("the steps header carries the animated indicator while running", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity(
[{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }],
msg, true);
const summary = trace.querySelector("summary");
const dots = summary.querySelector(".bookslm-steps-dots");
assert.ok(dots, "dots shown before the label while the agent works");
assert.equal(dots.querySelectorAll("i").length, 3, "three animated dots");
assert.equal(dots.getAttribute("aria-hidden"), "true");
assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers");
});
await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => {
const b = new BooksLM();
const msg = {};
const calls = [
{ name: "", ok: true, step: { key: "thought", params: { value: "Je dois d'abord lister le vault." } } },
{ name: "list_vaults", ok: true, step: { key: "vaults", params: {} } },
];
const trace = b._renderToolActivity(calls, msg, false);
const thought = trace.querySelector("details.bookslm-thought");
assert.ok(thought, "the note is its own collapsible sub-section");
assert.ok(thought.querySelector(".bookslm-thought-label"), "labelled (Réflexion / Thought)");
assert.equal(thought.querySelector(".bookslm-thought-text").textContent,
"Je dois d'abord lister le vault.", "the thought text sits inside");
assert.equal(thought.open, false, "collapsed by default → ▶");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 1, "the note is not a plain line");
// Expanding is remembered so the per-token re-render cannot collapse it.
thought.open = true;
thought.dispatchEvent(new Event("toggle"));
assert.equal(msg._thoughtOpen["Je dois d'abord lister le vault."], true);
const again = b._renderToolActivity(calls, msg, false);
assert.equal(again.querySelector("details.bookslm-thought").open, true,
"the open state survives a re-render");
});
await test("web searches add a sources section with clickable links", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([
{
name: "web_search", ok: true, step: { key: "web_search", params: { value: "météo Paris" } },
sources: [
{ title: "Météo Paris", url: "https://exemple.fr/meteo" },
{ title: "", url: "https://exemple.fr/b" },
],
},
], msg, false);
const box = trace.querySelector("details.bookslm-web-sources");
assert.ok(box, "sources section present");
assert.equal(box.open, true, "visible by default so the links show up");
const links = box.querySelectorAll(".bookslm-web-sources-list a");
assert.equal(links.length, 2, "one link per source");
assert.equal(links[0].getAttribute("href"), "https://exemple.fr/meteo");
assert.equal(links[0].target, "_blank");
assert.equal(links[0].rel, "noopener noreferrer");
assert.equal(links[1].textContent, "https://exemple.fr/b", "falls back to the URL label");
// No web search → no sources section.
const bare = b._renderToolActivity([{ name: "read_file", ok: true }], {}, false);
assert.equal(bare.querySelector(".bookslm-web-sources"), null);
});
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("<h1>Title</h1>"), "heading rendered");
assert.ok(html.includes("<strong>bold</strong>"), "bold rendered");
assert.ok(html.includes("<code>code</code>"), "inline code rendered");
assert.ok(html.includes("<ul><li>one</li><li>two</li></ul>"), "list rendered");
assert.ok(html.includes("<table>") && html.includes("<th>A</th>") && html.includes("<td>1</td>"), "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('<img src=x onerror="alert(1)">');
assert.ok(!html.includes("<img"), "raw HTML must be escaped");
assert.ok(html.includes("&lt;img"), "escaped output present");
});
// ── 12. File/path mentions become clickable links ──
await test("_renderMarkdown turns file and path mentions into links", () => {
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</a>"), "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 (#95/#96 — persistent backend history) ──
await test("sessions persist and can be reopened and deleted", async () => {
localStorage.clear();
// Mock server to avoid network calls in tests
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/bookslm/history")) {
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._panel = document.createElement("div");
b._mode = MODE.DIRECTORY;
b._vault = "VaultSessions";
b._directory = "notes";
await b._loadHistory();
assert.ok(b._sessions.length >= 1, "at least one session on first load (local or server)");
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();
await 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", async () => {
localStorage.clear();
// Mock server to avoid network calls in tests
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/ai/bookslm/history")) {
if (u.includes("PUT")) return { ok: true, status: 200, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ sessions: [] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const b = new BooksLM();
b._mode = MODE.GENERAL;
localStorage.setItem(
b._legacyHistoryKey(),
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
);
await b._loadHistory();
assert.equal(b._sessions.length, 1, "legacy history imported as a session");
assert.ok(b._messages.length >= 1, "messages from legacy session available");
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 (#91 — plus de barre de chargement) ──
await test("no loading bar above the composer; activity is recorded as state", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
assert.equal(b._panel.querySelector(".bookslm-activity"), null,
"the composer loading bar is gone");
// _setActivity stays the single entry point of the streaming code paths.
b._setActivity("working", "Envoi…");
assert.equal(b._activityKind, "working");
assert.equal(b._activityText, "Envoi…");
b._setActivity("idle");
assert.equal(b._activityKind, "idle");
b._panel.remove();
});
await test("plain chat shows the pending indicator inside the empty answer", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._messages = [{ role: "assistant", content: "", toolCalls: [], sources: [] }];
b._isLoading = true;
b._renderMessages();
const pending = b._panel.querySelector(".bookslm-bubble.assistant .bookslm-pending .bookslm-dots");
assert.ok(pending, "animated dots shown while the first token is pending");
assert.equal(pending.querySelectorAll("i").length, 3, "three dots");
b._isLoading = false;
b._renderMessages();
assert.equal(b._panel.querySelector(".bookslm-pending"), null, "indicator gone once settled");
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);
});