Le picker lit /api/ai/status une seule fois, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session : ajouter ou supprimer une clé API dans la configuration du projet laissait la liste des fournisseurs figée jusqu'à un rechargement de page. - ai.js : nouveau refreshAIPickers() qui reconstruit chaque picker monté dans son emplacement .ai-picker-slot (constante PICKER_SLOT_CLASS) ; le slot est conservé même sans fournisseur configuré, donc le premier fournisseur ajouté s'y monte aussi ; une sélection persistée dont le fournisseur n'est plus configuré est purgée de obsigate_ai_picker (retour au défaut, plus de modèle fantôme dans le déclencheur). - bookslm.js : l'emplacement .bookslm-picker-host porte la classe ai-picker-slot et reste dans la barre (replaceChildren au lieu de replaceWith). - config.js : refreshAIPickers() après saveAIKeys() et deleteAIKey(). - tests : +4 tests JSDOM (ajout/retrait dans la barre, premier montage dans un slot vide, purge de la sélection orpheline, câblage save/delete). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff 0, et contrôle navigateur (Playwright) sur l'instance de test — ajout de nvidia visible sans rechargement, retrait effectif + sélection réinitialisée.
1447 lines
61 KiB
JavaScript
1447 lines
61 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;
|
|
|
|
// ── 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();
|
|
});
|
|
|
|
// ── 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("<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 ──
|
|
await test("sessions persist and can be reopened and deleted", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._panel = document.createElement("div");
|
|
b._mode = MODE.DIRECTORY;
|
|
b._vault = "VaultSessions";
|
|
b._directory = "notes";
|
|
b._loadHistory();
|
|
assert.equal(b._sessions.length, 1, "one session created on first load");
|
|
|
|
b._messages.push({ role: "user", content: "Première question" });
|
|
b._messages.push({ role: "assistant", content: "Réponse" });
|
|
b._saveHistory();
|
|
const firstId = b._currentSessionId;
|
|
|
|
b.newConversation();
|
|
assert.equal(b._sessions.length, 2, "new conversation adds a session");
|
|
assert.notEqual(b._currentSessionId, firstId, "current session switched");
|
|
b._messages.push({ role: "user", content: "Deuxième" });
|
|
b._saveHistory();
|
|
|
|
b._loadSession(firstId);
|
|
assert.equal(b._messages.length, 2, "previous session messages restored");
|
|
assert.equal(b._messages[0].content, "Première question");
|
|
const first = b._sessions.find((s) => s.id === firstId);
|
|
assert.ok(first && first.title.includes("Première"), "session titled from first user message");
|
|
|
|
b._deleteSession(firstId);
|
|
assert.equal(b._sessions.length, 1, "session deleted");
|
|
assert.ok(!b._sessions.find((s) => s.id === firstId), "deleted session gone");
|
|
});
|
|
|
|
await test("history menu lists saved sessions", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._mode = MODE.DIRECTORY;
|
|
b._vault = "VaultMenu";
|
|
b._directory = "d";
|
|
b._loadHistory();
|
|
b._messages.push({ role: "user", content: "Hello menu" });
|
|
b._saveHistory();
|
|
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._renderHistoryMenu();
|
|
const items = b._panel.querySelectorAll(".bookslm-history-item");
|
|
assert.equal(items.length, 1, "one session listed");
|
|
assert.ok(items[0].textContent.includes("Hello menu"), "session title shown");
|
|
b._panel.remove();
|
|
});
|
|
|
|
await test("newConversation before open defers a fresh session", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b.newConversation();
|
|
assert.equal(b._pendingNewSession, true, "fresh session deferred");
|
|
await b.openContext({ mode: MODE.GENERAL });
|
|
assert.equal(b._pendingNewSession, false, "deferred session applied on open");
|
|
assert.equal(b._sessions.length, 1, "fresh session created");
|
|
b.close();
|
|
});
|
|
|
|
await test("legacy single-conversation history is migrated", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._mode = MODE.GENERAL;
|
|
localStorage.setItem(
|
|
b._legacyHistoryKey(),
|
|
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
|
|
);
|
|
b._loadHistory();
|
|
assert.equal(b._sessions.length, 1, "legacy history imported as a session");
|
|
assert.equal(b._messages[0].content, "Ancienne conversation");
|
|
});
|
|
|
|
// ── 14. Agent mode & confirmations (B5) ──
|
|
await test("agent mode toggle persists and switches the endpoint", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
assert.equal(b._agentMode, false, "agent mode off by default");
|
|
b._toggleAgentMode();
|
|
assert.equal(b._agentMode, true);
|
|
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
|
|
const b2 = new BooksLM();
|
|
assert.equal(b2._agentMode, true, "persisted across instances");
|
|
|
|
let url = null;
|
|
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
|
|
b2._abortCtrl = null;
|
|
await b2._postChat({ message: "x" });
|
|
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
|
|
b2._agentMode = false;
|
|
await b2._postChat({ message: "x" });
|
|
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("header exposes the agent-mode toggle", () => {
|
|
const b = new BooksLM();
|
|
const panel = b._render();
|
|
b._panel = panel;
|
|
document.body.appendChild(panel);
|
|
const btn = panel.querySelector(".bookslm-btn-agent");
|
|
assert.ok(btn, "agent toggle button present");
|
|
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
|
btn.click();
|
|
assert.equal(btn.getAttribute("aria-pressed"), "true");
|
|
panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("_lineDiff marks additions and deletions", () => {
|
|
const b = new BooksLM();
|
|
const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd");
|
|
const adds = diff.filter((l) => l.t === "+").map((l) => l.s);
|
|
const dels = diff.filter((l) => l.t === "-").map((l) => l.s);
|
|
assert.ok(dels.includes("b"), "removed line marked");
|
|
assert.ok(adds.includes("B"), "changed line marked as addition");
|
|
assert.ok(adds.includes("d"), "appended line marked");
|
|
});
|
|
|
|
await test("confirmation card resumes the agent run with confirm payload", async () => {
|
|
localStorage.clear();
|
|
const encoder = new TextEncoder();
|
|
let posted = null;
|
|
const sse =
|
|
'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' +
|
|
'event: message\ndata: {"token":"Fait."}\n\n' +
|
|
"event: done\ndata: {}\n\n";
|
|
globalThis.fetch = async (url, opts) => {
|
|
if (String(url).includes("/api/ai/bookslm/agent")) {
|
|
posted = JSON.parse(opts.body);
|
|
const chunks = [encoder.encode(sse)];
|
|
const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) };
|
|
return { ok: true, status: 200, body: { getReader: () => reader } };
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({ raw: "old content" }) };
|
|
};
|
|
|
|
const b = new BooksLM();
|
|
b._agentMode = true;
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const msg = {
|
|
role: "assistant",
|
|
content: "",
|
|
sources: [],
|
|
toolCalls: [],
|
|
confirmation: {
|
|
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } },
|
|
messages: [{ role: "system", content: "s" }],
|
|
},
|
|
payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] },
|
|
};
|
|
b._messages = [msg];
|
|
|
|
await b._applyConfirmation(msg);
|
|
|
|
assert.equal(msg.confirmation, null, "confirmation resolved");
|
|
assert.ok(posted, "resumed via /agent");
|
|
assert.equal(posted.confirm.error.tool, "edit_file");
|
|
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
|
|
const cont = b._messages[b._messages.length - 1];
|
|
assert.equal(cont.content, "Fait.");
|
|
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 29. Resizable panel bounds and persistence (#81) ──
|
|
await test("panel width is clamped and persisted", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
assert.equal(b._clampPanelWidth(100), 320, "min width enforced");
|
|
assert.equal(b._clampPanelWidth(5000), 1000, "max width enforced");
|
|
localStorage.setItem("obsigate-bookslm-width", "640");
|
|
b._applyPanelWidth();
|
|
assert.equal(b._panel.style.width, "640px", "persisted width applied");
|
|
assert.ok(b._panel.querySelector(".bookslm-resize-handle"), "resize handle present");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 30. Ad-hoc context chips (#81) ──
|
|
await test("ad-hoc files and directories render as chips", async () => {
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._addAdhocFile("notes/a.md");
|
|
b._addAdhocDir("projets/");
|
|
const chips = b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip");
|
|
assert.equal(chips.length, 2, "one chip per ad-hoc entry");
|
|
assert.ok(b._adhocDirs[0].path === "projets", "trailing slash stripped");
|
|
b._removeAdhocFile("notes/a.md");
|
|
assert.equal(b._panel.querySelectorAll(".bookslm-attachments .bookslm-chip").length, 1);
|
|
b._panel.remove();
|
|
});
|
|
|
|
// ── 31. Chat payload carries extras, images and skill (#81) ──
|
|
await test("send payload includes extra context, images and skill", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._mode = MODE.GENERAL;
|
|
b._vault = "V";
|
|
b._messages = [];
|
|
b._adhocFiles = [{ path: "a.md" }];
|
|
b._adhocDirs = [{ path: "sub" }];
|
|
b._images = [{ path: "pics/x.png", name: "x.png" }];
|
|
b._activeSkill = "research";
|
|
b._panel.querySelector("textarea").value = "Analyse";
|
|
|
|
let posted = null;
|
|
globalThis.fetch = async (url, opts) => {
|
|
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
|
|
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
|
|
};
|
|
await b._sendMessage();
|
|
|
|
assert.ok(posted, "chat request posted");
|
|
assert.deepEqual(posted.extra_files, ["a.md"]);
|
|
assert.deepEqual(posted.extra_directories, ["sub"]);
|
|
assert.deepEqual(posted.images, [{ path: "pics/x.png" }]);
|
|
assert.equal(posted.skill, "research");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
|
|
await test("images route to /chat even in agent mode", async () => {
|
|
const b = new BooksLM();
|
|
b._agentMode = true;
|
|
let url = "";
|
|
globalThis.fetch = async (u) => {
|
|
url = String(u);
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
await b._postChat({ images: [{ data: "AAA", mime_type: "image/png" }] });
|
|
assert.ok(url.includes("/api/ai/bookslm/chat"), "image request uses /chat");
|
|
await b._postChat({ images: [] });
|
|
assert.ok(url.includes("/api/ai/bookslm/agent"), "text request uses /agent in agent mode");
|
|
});
|
|
|
|
// ── 33. Vision gate (#81) ──
|
|
await test("_modelSupportsVision reflects selected model capabilities", async () => {
|
|
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
|
provider: "deepseek", model: "deepseek-chat", capabilities: { chat: true, vision: false },
|
|
}));
|
|
const b = new BooksLM();
|
|
assert.equal(await b._modelSupportsVision(), false);
|
|
localStorage.setItem("obsigate_ai_picker", JSON.stringify({
|
|
provider: "qwencloud", model: "qwen-vl-max", capabilities: { chat: true, vision: true },
|
|
}));
|
|
assert.equal(await b._modelSupportsVision(), true);
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 34. `/` command menu and selection (#81) ──
|
|
await test("slash command menu lists skills and selects one", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/ai/skills")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
skills: [{ id: "research", label: "Recherche", icon: "🔎", type: "skill", description: "d" }],
|
|
commands: [{ id: "help", label: "Aide", icon: "❓", type: "admin", description: "h" }],
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const ta = b._panel.querySelector("textarea");
|
|
ta.value = "/res";
|
|
ta.selectionStart = ta.selectionEnd = 4;
|
|
await b._showCommandMenu("res");
|
|
const menu = b._panel.querySelector(".bookslm-command-menu");
|
|
assert.ok(!menu.classList.contains("hidden"), "command menu shown");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
|
b._selectCommand({ id: "research", label: "Recherche", type: "skill" });
|
|
assert.equal(b._activeSkill, "research");
|
|
assert.ok(b._panel.querySelector(".bookslm-chip-skill"), "skill chip rendered");
|
|
b._panel.remove();
|
|
});
|
|
|
|
// ── 35. `@` mention menu adds ad-hoc context / images (#81) ──
|
|
await test("mention menu adds files, directories and images", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/tree-search")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({ results: [
|
|
{ path: "notes/a.md", type: "file" },
|
|
{ path: "notes", type: "dir" },
|
|
] }),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._vault = "V";
|
|
await b._showMentionMenu("notes");
|
|
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2);
|
|
b._selectMention({ id: "notes/a.md", type: "file" });
|
|
assert.ok(b._adhocFiles.some((f) => f.path === "notes/a.md"));
|
|
b._selectMention({ id: "notes", type: "dir" });
|
|
assert.ok(b._adhocDirs.some((d) => d.path === "notes"));
|
|
b._selectMention({ id: "pics/x.png", type: "file" });
|
|
assert.ok(b._images.some((img) => img.path === "pics/x.png"));
|
|
b._panel.remove();
|
|
});
|
|
|
|
// ── 35b. `@` captures the vault for General-mode ad-hoc context (BUG-010) ──
|
|
await test("mention selection stores its vault and feeds the context request", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/tree-search")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({ results: [{ vault: "TestVault", path: "notes/a.md", type: "file" }] }),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._mode = MODE.GENERAL;
|
|
b._vault = null;
|
|
|
|
await b._showMentionMenu("notes");
|
|
b._selectMention({ id: "notes/a.md", type: "file", vault: "TestVault" });
|
|
assert.equal(b._adhocFiles[0].vault, "TestVault", "ad-hoc entry keeps its vault");
|
|
assert.equal(b._contextVault(), "TestVault", "context vault derives from the ad-hoc entry");
|
|
|
|
let posted = null;
|
|
globalThis.fetch = async (url, opts) => {
|
|
if (String(url).includes("/api/ai/bookslm/context")) posted = JSON.parse(opts.body);
|
|
return { ok: true, status: 200, json: async () => ({ files: [], scope: "documents" }) };
|
|
};
|
|
await b._loadContext();
|
|
assert.equal(posted.vault, "TestVault", "context request carries the vault");
|
|
assert.deepEqual(posted.extra_files, ["notes/a.md"]);
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 36. Capability checklist rendering (#81) ──
|
|
await test("capability list renders checked and unchecked flags", () => {
|
|
const caps = {
|
|
chat: true, embeddings: false, rerank: false, images: false,
|
|
video: false, audio_speech: false, audio_transcription: false, vision: true,
|
|
};
|
|
const box = ai.renderCapabilityList(caps);
|
|
assert.equal(box.querySelectorAll(".ai-cap-item").length, 8);
|
|
assert.equal(box.querySelectorAll(".ai-cap-item.on").length, 2);
|
|
assert.ok(box.textContent.includes("☑"));
|
|
assert.ok(box.textContent.includes("□"));
|
|
});
|
|
|
|
// ── 37. Admin commands handled locally (#81) ──
|
|
await test("admin commands are parsed without an LLM call", async () => {
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._messages = [];
|
|
let fetched = false;
|
|
globalThis.fetch = async () => { fetched = true; return { ok: true, status: 200, json: async () => ({}) }; };
|
|
const handled = b._maybeRunAdminWithArg("/model deepseek-chat");
|
|
assert.equal(handled, true);
|
|
assert.equal(fetched, false, "no LLM request for admin command");
|
|
assert.ok(b._messages.length === 1, "assistant note pushed");
|
|
b._panel.remove();
|
|
});
|
|
|
|
// ── 38. Accent-insensitive `/` command filtering (BUG-007) ──
|
|
await test("slash command filter is accent-insensitive", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/ai/skills")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
skills: [{ id: "resume", label: "Résumé", icon: "📄", type: "skill", description: "d" }],
|
|
commands: [],
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const ta = b._panel.querySelector("textarea");
|
|
|
|
ta.value = "/resume";
|
|
ta.selectionStart = ta.selectionEnd = 7;
|
|
b._onComposerInput();
|
|
await sleep(20);
|
|
let menu = b._panel.querySelector(".bookslm-command-menu");
|
|
assert.ok(!menu.classList.contains("hidden"), "ascii query keeps the menu open");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
|
|
|
ta.value = "/résumé";
|
|
ta.selectionStart = ta.selectionEnd = 7;
|
|
b._onComposerInput();
|
|
await sleep(20);
|
|
menu = b._panel.querySelector(".bookslm-command-menu");
|
|
assert.ok(!menu.classList.contains("hidden"), "accented query keeps the menu open");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
|
|
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
|
|
localStorage.clear();
|
|
let lastUrl = "";
|
|
globalThis.fetch = async (url) => {
|
|
lastUrl = String(url);
|
|
if (lastUrl.includes("/api/tree-search")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._vault = null;
|
|
const ta = b._panel.querySelector("textarea");
|
|
ta.value = "@café";
|
|
ta.selectionStart = ta.selectionEnd = 5;
|
|
b._onComposerInput();
|
|
await sleep(20);
|
|
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
|
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
|
|
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
|
|
await test("arrow keys move the active item in the command menu", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/ai/skills")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
skills: [
|
|
{ id: "a", label: "Alpha", type: "skill", description: "d" },
|
|
{ id: "b", label: "Beta", type: "skill", description: "d" },
|
|
{ id: "c", label: "Gamma", type: "skill", description: "d" },
|
|
],
|
|
commands: [],
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const ta = b._panel.querySelector("textarea");
|
|
ta.value = "/";
|
|
ta.selectionStart = ta.selectionEnd = 1;
|
|
b._onComposerInput();
|
|
await sleep(20);
|
|
const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item"));
|
|
assert.equal(items().length, 3);
|
|
assert.ok(items()[0].classList.contains("active"), "first item active by default");
|
|
|
|
const down = new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true });
|
|
ta.dispatchEvent(down);
|
|
assert.ok(down.defaultPrevented, "ArrowDown handled by the menu");
|
|
assert.ok(items()[1].classList.contains("active"), "second item active after ArrowDown");
|
|
|
|
ta.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true }));
|
|
assert.ok(items()[0].classList.contains("active"), "back to first after ArrowUp");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 41. Compact picker: model search + capability info bubble (#82) ──
|
|
await test("picker model search filters and info reveals capabilities", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
const u = String(url);
|
|
if (u.includes("/api/ai/status")) {
|
|
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true } } }) };
|
|
}
|
|
if (u.includes("/api/config/ai-models")) {
|
|
return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner", "vision-model"], source: "api" }) };
|
|
}
|
|
if (u.includes("/api/ai/model-capabilities")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
capabilities: { chat: true, embeddings: false, rerank: false, images: false, video: false, audio_speech: false, audio_transcription: false, vision: false },
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "deepseek" }));
|
|
const picker = await ai.buildAIPickerUI();
|
|
assert.ok(picker, "picker built");
|
|
document.body.appendChild(picker);
|
|
await sleep(30);
|
|
|
|
const visible = Array.from(picker.children).filter((el) => !el.hidden);
|
|
assert.equal(visible.length, 3, "three visible controls");
|
|
assert.ok(visible[0].classList.contains("ai-picker-caps-host"), "capability info comes first");
|
|
assert.ok(visible[1].classList.contains("ai-picker-select"), "provider menu comes second");
|
|
assert.ok(visible[2].classList.contains("ai-picker-model-combo"), "model menu comes third");
|
|
|
|
const hidden = picker.querySelector(".ai-picker-model");
|
|
assert.ok(hidden && hidden.hidden, "hidden native select kept for admin commands");
|
|
assert.ok(hidden.querySelectorAll("option").length >= 4, "native select holds default + models");
|
|
|
|
picker.querySelector(".ai-picker-model-trigger").click();
|
|
const pop = picker.querySelector(".ai-picker-model-pop");
|
|
assert.ok(!pop.classList.contains("hidden"), "model popover opens");
|
|
assert.equal(pop.querySelectorAll(".ai-picker-model-option").length, 4, "default + 3 models");
|
|
|
|
const search = picker.querySelector(".ai-picker-model-search");
|
|
search.value = "reason";
|
|
search.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
const filtered = pop.querySelectorAll(".ai-picker-model-option");
|
|
assert.equal(filtered.length, 1, "search filters the list");
|
|
assert.equal(filtered[0].dataset.value, "deepseek-reasoner");
|
|
|
|
filtered[0].dispatchEvent(new w.Event("mousedown", { bubbles: true }));
|
|
assert.equal(hidden.value, "deepseek-reasoner", "selection updates the native select");
|
|
const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker"));
|
|
assert.equal(stored.model, "deepseek-reasoner");
|
|
|
|
await sleep(20);
|
|
const info = picker.querySelector(".ai-picker-info");
|
|
assert.ok(!info.classList.contains("hidden"), "info button visible once capabilities loaded");
|
|
info.click();
|
|
const capsPop = picker.querySelector(".ai-picker-caps-pop");
|
|
assert.ok(!capsPop.classList.contains("hidden"), "capability bubble opens");
|
|
assert.equal(capsPop.querySelectorAll(".ai-cap-item").length, 8, "all capability flags listed in the bubble");
|
|
picker.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 42. Picker handles very long model lists (OpenRouter) ──
|
|
await test("picker caps long model lists and keeps options block-laid-out", async () => {
|
|
localStorage.clear();
|
|
const models = Array.from({ length: 250 }, (_, i) => `vendor-${i}/model-${i}:free`);
|
|
globalThis.fetch = async (url) => {
|
|
const u = String(url);
|
|
if (u.includes("/api/ai/status")) {
|
|
return { ok: true, status: 200, json: async () => ({ configured: true, providers: { openrouter: { available: true } } }) };
|
|
}
|
|
if (u.includes("/api/config/ai-models")) {
|
|
return { ok: true, status: 200, json: async () => ({ models, source: "live" }) };
|
|
}
|
|
if (u.includes("/api/ai/model-capabilities")) {
|
|
return { ok: true, status: 200, json: async () => ({ capabilities: { chat: true, vision: true } }) };
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
localStorage.setItem("obsigate_ai_picker", JSON.stringify({ provider: "openrouter" }));
|
|
const picker = await ai.buildAIPickerUI();
|
|
document.body.appendChild(picker);
|
|
await sleep(30);
|
|
picker.querySelector(".ai-picker-model-trigger").click();
|
|
const options = picker.querySelectorAll(".ai-picker-model-option");
|
|
assert.equal(options.length, 200, "rendered options are capped");
|
|
assert.ok(picker.querySelector(".ai-picker-model-more"), "a 'more results' hint is shown");
|
|
assert.ok(options[0].style.display === "block", "options are block-laid-out even without CSS");
|
|
picker.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 42c. BUG-043: the provider list follows the project AI configuration ──
|
|
const providerOptions = (root) =>
|
|
Array.from(root.querySelectorAll(".ai-picker-select option"))
|
|
.map((o) => o.value)
|
|
.filter(Boolean);
|
|
|
|
const providerStatusMock = (getProviders, models = ["deepseek-chat"]) => async (url) => {
|
|
const u = String(url);
|
|
if (u.includes("/api/ai/status")) {
|
|
return { ok: true, status: 200, json: async () => ({ configured: true, providers: getProviders() }) };
|
|
}
|
|
if (u.includes("/api/config/ai-models")) {
|
|
return { ok: true, status: 200, json: async () => ({ models, source: "api" }) };
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
|
|
await test("assistant sidebar lists a provider added or removed in the project config", async () => {
|
|
localStorage.clear();
|
|
let providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
|
globalThis.fetch = providerStatusMock(() => providers);
|
|
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
await sleep(30);
|
|
const host = b._panel.querySelector(".bookslm-picker-host");
|
|
assert.ok(host, "picker host lives in the toolbar");
|
|
assert.deepEqual(providerOptions(host), ["deepseek"], "only the configured provider is listed");
|
|
|
|
// Admin saves a second provider key in the project config.
|
|
providers = {
|
|
deepseek: { available: true, model: "deepseek-chat" },
|
|
nvidia: { available: true, model: "meta/llama-3.1-405b-instruct" },
|
|
};
|
|
await ai.refreshAIPickers();
|
|
await sleep(10);
|
|
assert.deepEqual(providerOptions(host), ["deepseek", "nvidia"], "added provider shows up without a reload");
|
|
assert.ok(host.querySelector(".ai-picker"), "picker rebuilt inside its host slot");
|
|
assert.equal(b._panel.querySelectorAll(".ai-picker").length, 1, "no duplicate picker mount");
|
|
|
|
// Admin deletes the key again.
|
|
providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
|
await ai.refreshAIPickers();
|
|
await sleep(10);
|
|
assert.deepEqual(providerOptions(host), ["deepseek"], "removed provider disappears");
|
|
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("refreshAIPickers mounts the first provider into an empty slot", async () => {
|
|
localStorage.clear();
|
|
let providers = {};
|
|
globalThis.fetch = providerStatusMock(() => providers);
|
|
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
await sleep(30);
|
|
const host = b._panel.querySelector(".bookslm-picker-host");
|
|
assert.equal(host.querySelector(".ai-picker"), null, "no picker without a configured provider");
|
|
|
|
providers = { deepseek: { available: true, model: "deepseek-chat" } };
|
|
await ai.refreshAIPickers();
|
|
await sleep(10);
|
|
assert.ok(host.querySelector(".ai-picker"), "picker mounted into the kept slot");
|
|
assert.deepEqual(providerOptions(host), ["deepseek"]);
|
|
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("a provider whose key was deleted is dropped from the stored selection", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = providerStatusMock(() => ({ deepseek: { available: true } }));
|
|
|
|
localStorage.setItem(
|
|
"obsigate_ai_picker",
|
|
JSON.stringify({ provider: "nvidia", model: "meta/llama-3.1-405b-instruct" })
|
|
);
|
|
const slot = document.createElement("span");
|
|
slot.className = "ai-picker-slot";
|
|
document.body.appendChild(slot);
|
|
slot.replaceChildren(await ai.buildAIPickerUI());
|
|
await sleep(20);
|
|
|
|
assert.equal(slot.querySelector(".ai-picker-select").value, "", "provider falls back to the default");
|
|
assert.equal(slot.querySelector(".ai-picker-model").value, "", "no stale model kept");
|
|
const stored = JSON.parse(localStorage.getItem("obsigate_ai_picker"));
|
|
assert.equal(stored.provider, null, "unconfigured provider cleared from storage");
|
|
assert.equal(stored.model, null, "its model cleared from storage");
|
|
|
|
slot.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("config save/delete rebuilds the assistant provider picker", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const src = readFileSync(path.join(JS_DIR, "config.js"), "utf-8");
|
|
const save = src.slice(src.indexOf("async function saveAIKeys()"), src.indexOf("async function deleteAIKey("));
|
|
const del = src.slice(src.indexOf("async function deleteAIKey("), src.indexOf("async function testAIKeys("));
|
|
assert.ok(save.includes("refreshAIPickers()"), "saving a provider key refreshes the picker");
|
|
assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker");
|
|
});
|
|
|
|
// ── 42b. Assistant activity indicator (#82) ──
|
|
await test("activity indicator reflects the request workflow", () => {
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const el = b._panel.querySelector(".bookslm-activity");
|
|
assert.ok(el, "activity element present");
|
|
assert.ok(el.classList.contains("hidden"), "hidden initially");
|
|
b._setActivity("working", "Envoi…");
|
|
assert.ok(!el.classList.contains("hidden"), "shown while working");
|
|
assert.ok(el.classList.contains("working"));
|
|
assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…");
|
|
b._setActivity("error", "Échec");
|
|
assert.ok(el.classList.contains("error"));
|
|
b._setActivity("idle");
|
|
assert.ok(el.classList.contains("hidden"), "hidden when idle");
|
|
b._panel.remove();
|
|
});
|
|
|
|
await test("mention menu falls back to an available vault", async () => {
|
|
localStorage.clear();
|
|
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
|
|
const prev = stateMod.state.allVaults;
|
|
stateMod.state.allVaults = [{ name: "FallbackVault" }];
|
|
let lastUrl = "";
|
|
globalThis.fetch = async (url) => {
|
|
lastUrl = String(url);
|
|
if (lastUrl.includes("/files")) {
|
|
return { ok: true, status: 200, json: async () => ({ files: [{ path: "a.md" }] }) };
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._mode = MODE.GENERAL;
|
|
b._vault = null;
|
|
await b._showMentionMenu("");
|
|
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
|
assert.ok(!menu.classList.contains("hidden"), "empty-query menu is shown");
|
|
assert.ok(lastUrl.includes("FallbackVault"), "uses the fallback vault");
|
|
stateMod.state.allVaults = prev;
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("active vault falls back to the first available vault", async () => {
|
|
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
|
|
const prevVaults = stateMod.state.allVaults;
|
|
const prevSelected = stateMod.state.selectedContextVault;
|
|
stateMod.state.allVaults = [{ name: "V1" }, { name: "V2" }];
|
|
stateMod.state.selectedContextVault = "all";
|
|
const b = new BooksLM();
|
|
b._vault = null;
|
|
assert.equal(b._activeVault(), "V1", "first vault used for links/@ browsing");
|
|
stateMod.state.selectedContextVault = "V2";
|
|
assert.equal(b._activeVault(), "V2", "selected context vault takes precedence");
|
|
stateMod.state.allVaults = prevVaults;
|
|
stateMod.state.selectedContextVault = prevSelected;
|
|
});
|
|
|
|
await test("menu navigation works from the panel, not only the textarea", async () => {
|
|
localStorage.clear();
|
|
globalThis.fetch = async (url) => {
|
|
if (String(url).includes("/api/ai/skills")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({
|
|
skills: [
|
|
{ id: "a", label: "Alpha", type: "skill", description: "d" },
|
|
{ id: "b", label: "Beta", type: "skill", description: "d" },
|
|
],
|
|
commands: [],
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const ta = b._panel.querySelector("textarea");
|
|
ta.value = "/";
|
|
ta.selectionStart = ta.selectionEnd = 1;
|
|
b._onComposerInput();
|
|
await sleep(20);
|
|
const items = () => Array.from(b._panel.querySelectorAll(".bookslm-command-menu .bookslm-menu-item"));
|
|
assert.equal(items().length, 2);
|
|
|
|
// Dispatch on the panel itself (focus elsewhere) — the capture handler must
|
|
// still navigate the menu.
|
|
b._panel.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
|
assert.ok(items()[1].classList.contains("active"), "ArrowDown from the panel moves the highlight");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 42c. Fast `@` menu: client-side filtering of the prefetched path index ──
|
|
await test("mention menu filters the prefetched path index instantly", async () => {
|
|
localStorage.clear();
|
|
const calls = [];
|
|
globalThis.fetch = async (url) => {
|
|
const u = String(url);
|
|
calls.push(u);
|
|
if (u.includes("/paths")) {
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({ vault: "V", count: 3, results: [
|
|
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
|
|
{ vault: "V", path: "notes/b.md", name: "b.md", type: "file" },
|
|
{ vault: "V", path: "projects", name: "projects", type: "directory" },
|
|
] }),
|
|
};
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({}) };
|
|
};
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._vault = "V";
|
|
await b._showMentionMenu("notes");
|
|
const menu = b._panel.querySelector(".bookslm-mention-menu");
|
|
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 2, "client-side filter keeps notes/*");
|
|
assert.ok(!calls.some((u) => u.includes("/api/tree-search")), "no server search once the path cache is warm");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("vault root context menu exposes BooksLM", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const src = readFileSync(path.join(JS_DIR, "ui.js"), "utf-8");
|
|
const vaultBranch = src.slice(
|
|
src.indexOf("if (type === 'vault')"),
|
|
src.indexOf("} else if (type === 'directory')")
|
|
);
|
|
assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu");
|
|
});
|
|
|
|
// ── BUG-034: the access token is not persisted in sessionStorage ──
|
|
await test("auth token kept in memory, not in sessionStorage", async () => {
|
|
const authMod = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
|
|
const { AuthManager } = authMod;
|
|
sessionStorage.clear();
|
|
AuthManager.saveToken({
|
|
access_token: "secret-token",
|
|
expires_in: 3600,
|
|
user: { username: "u", role: "user" },
|
|
});
|
|
assert.equal(sessionStorage.getItem("obsigate_access_token"), null, "token must not be stored");
|
|
assert.equal(AuthManager.getToken(), "secret-token", "token available in memory");
|
|
assert.ok(AuthManager.hasSession(), "session detected");
|
|
AuthManager.clearSession();
|
|
assert.equal(AuthManager.getToken(), null, "session cleared");
|
|
});
|
|
|
|
// ── Summary ──
|
|
console.log(`\n${passCount}/${testCount} tests passed`);
|
|
if (passCount !== testCount) {
|
|
process.exit(1);
|
|
}}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|