BUG-011: popover des modeles aligne a droite (right:0), largeur 340px bornee, noms sur plusieurs lignes + title. BUG-010: la selection @ capture le vault renvoye par tree-search et _contextVault() le propage aux requetes /context et /chat, corrigeant le contexte ad-hoc en mode General.
1037 lines
43 KiB
JavaScript
1037 lines
43 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 shown");
|
|
panel.remove();
|
|
});
|
|
|
|
// ── 11. Formatted markdown rendering ──
|
|
await test("_renderMarkdown renders headings, lists, code and tables", () => {
|
|
const b = new BooksLM();
|
|
const html = b._renderMarkdown([
|
|
"# Title",
|
|
"",
|
|
"Some **bold** and `code` text.",
|
|
"",
|
|
"- one",
|
|
"- two",
|
|
"",
|
|
"| A | B |",
|
|
"| --- | --- |",
|
|
"| 1 | 2 |",
|
|
"",
|
|
"```js",
|
|
"const x = 1;",
|
|
"```",
|
|
].join("\n"));
|
|
assert.ok(html.includes("<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("_renderMarkdown keeps external links external", () => {
|
|
const b = new BooksLM();
|
|
const html = b._renderMarkdown("[site](https://example.com/page)");
|
|
assert.ok(html.includes('href="https://example.com/page"'), "external link kept");
|
|
assert.ok(!html.includes("bookslm-path-link"), "external link not treated as a vault path");
|
|
});
|
|
|
|
await test("clicking a path link opens a file or reveals a directory", () => {
|
|
const b = new BooksLM();
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const opened = [];
|
|
const revealed = [];
|
|
b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); };
|
|
b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); };
|
|
|
|
const container = b._panel.querySelector(".bookslm-messages");
|
|
container.innerHTML = b._renderMarkdown("`notes/a.md` et `dossier/sous-dossier`");
|
|
const fileLink = container.querySelector('a[data-kind="file"]');
|
|
const dirLink = container.querySelector('a[data-kind="dir"]');
|
|
assert.ok(fileLink, "file link rendered");
|
|
assert.ok(dirLink, "dir link rendered");
|
|
fileLink.click();
|
|
dirLink.click();
|
|
assert.deepEqual(opened, ["notes/a.md"]);
|
|
assert.deepEqual(revealed, ["dossier/sous-dossier"]);
|
|
b._panel.remove();
|
|
});
|
|
|
|
// ── 13. Session management ──
|
|
await test("sessions persist and can be reopened and deleted", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._panel = document.createElement("div");
|
|
b._mode = MODE.DIRECTORY;
|
|
b._vault = "VaultSessions";
|
|
b._directory = "notes";
|
|
b._loadHistory();
|
|
assert.equal(b._sessions.length, 1, "one session created on first load");
|
|
|
|
b._messages.push({ role: "user", content: "Première question" });
|
|
b._messages.push({ role: "assistant", content: "Réponse" });
|
|
b._saveHistory();
|
|
const firstId = b._currentSessionId;
|
|
|
|
b.newConversation();
|
|
assert.equal(b._sessions.length, 2, "new conversation adds a session");
|
|
assert.notEqual(b._currentSessionId, firstId, "current session switched");
|
|
b._messages.push({ role: "user", content: "Deuxième" });
|
|
b._saveHistory();
|
|
|
|
b._loadSession(firstId);
|
|
assert.equal(b._messages.length, 2, "previous session messages restored");
|
|
assert.equal(b._messages[0].content, "Première question");
|
|
const first = b._sessions.find((s) => s.id === firstId);
|
|
assert.ok(first && first.title.includes("Première"), "session titled from first user message");
|
|
|
|
b._deleteSession(firstId);
|
|
assert.equal(b._sessions.length, 1, "session deleted");
|
|
assert.ok(!b._sessions.find((s) => s.id === firstId), "deleted session gone");
|
|
});
|
|
|
|
await test("history menu lists saved sessions", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._mode = MODE.DIRECTORY;
|
|
b._vault = "VaultMenu";
|
|
b._directory = "d";
|
|
b._loadHistory();
|
|
b._messages.push({ role: "user", content: "Hello menu" });
|
|
b._saveHistory();
|
|
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
b._renderHistoryMenu();
|
|
const items = b._panel.querySelectorAll(".bookslm-history-item");
|
|
assert.equal(items.length, 1, "one session listed");
|
|
assert.ok(items[0].textContent.includes("Hello menu"), "session title shown");
|
|
b._panel.remove();
|
|
});
|
|
|
|
await test("newConversation before open defers a fresh session", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b.newConversation();
|
|
assert.equal(b._pendingNewSession, true, "fresh session deferred");
|
|
await b.openContext({ mode: MODE.GENERAL });
|
|
assert.equal(b._pendingNewSession, false, "deferred session applied on open");
|
|
assert.equal(b._sessions.length, 1, "fresh session created");
|
|
b.close();
|
|
});
|
|
|
|
await test("legacy single-conversation history is migrated", () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
b._mode = MODE.GENERAL;
|
|
localStorage.setItem(
|
|
b._legacyHistoryKey(),
|
|
JSON.stringify([{ role: "user", content: "Ancienne conversation" }])
|
|
);
|
|
b._loadHistory();
|
|
assert.equal(b._sessions.length, 1, "legacy history imported as a session");
|
|
assert.equal(b._messages[0].content, "Ancienne conversation");
|
|
});
|
|
|
|
// ── 14. Agent mode & confirmations (B5) ──
|
|
await test("agent mode toggle persists and switches the endpoint", async () => {
|
|
localStorage.clear();
|
|
const b = new BooksLM();
|
|
assert.equal(b._agentMode, false, "agent mode off by default");
|
|
b._toggleAgentMode();
|
|
assert.equal(b._agentMode, true);
|
|
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
|
|
const b2 = new BooksLM();
|
|
assert.equal(b2._agentMode, true, "persisted across instances");
|
|
|
|
let url = null;
|
|
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
|
|
b2._abortCtrl = null;
|
|
await b2._postChat({ message: "x" });
|
|
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
|
|
b2._agentMode = false;
|
|
await b2._postChat({ message: "x" });
|
|
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("header exposes the agent-mode toggle", () => {
|
|
const b = new BooksLM();
|
|
const panel = b._render();
|
|
b._panel = panel;
|
|
document.body.appendChild(panel);
|
|
const btn = panel.querySelector(".bookslm-btn-agent");
|
|
assert.ok(btn, "agent toggle button present");
|
|
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
|
btn.click();
|
|
assert.equal(btn.getAttribute("aria-pressed"), "true");
|
|
panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
await test("_lineDiff marks additions and deletions", () => {
|
|
const b = new BooksLM();
|
|
const diff = b._lineDiff("a\nb\nc", "a\nB\nc\nd");
|
|
const adds = diff.filter((l) => l.t === "+").map((l) => l.s);
|
|
const dels = diff.filter((l) => l.t === "-").map((l) => l.s);
|
|
assert.ok(dels.includes("b"), "removed line marked");
|
|
assert.ok(adds.includes("B"), "changed line marked as addition");
|
|
assert.ok(adds.includes("d"), "appended line marked");
|
|
});
|
|
|
|
await test("confirmation card resumes the agent run with confirm payload", async () => {
|
|
localStorage.clear();
|
|
const encoder = new TextEncoder();
|
|
let posted = null;
|
|
const sse =
|
|
'event: tool\ndata: {"name":"edit_file","ok":true,"arguments":{}}\n\n' +
|
|
'event: message\ndata: {"token":"Fait."}\n\n' +
|
|
"event: done\ndata: {}\n\n";
|
|
globalThis.fetch = async (url, opts) => {
|
|
if (String(url).includes("/api/ai/bookslm/agent")) {
|
|
posted = JSON.parse(opts.body);
|
|
const chunks = [encoder.encode(sse)];
|
|
const reader = { read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }) };
|
|
return { ok: true, status: 200, body: { getReader: () => reader } };
|
|
}
|
|
return { ok: true, status: 200, json: async () => ({ raw: "old content" }) };
|
|
};
|
|
|
|
const b = new BooksLM();
|
|
b._agentMode = true;
|
|
b._panel = b._render();
|
|
document.body.appendChild(b._panel);
|
|
const msg = {
|
|
role: "assistant",
|
|
content: "",
|
|
sources: [],
|
|
toolCalls: [],
|
|
confirmation: {
|
|
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "new content" }, id: "c1" } },
|
|
messages: [{ role: "system", content: "s" }],
|
|
},
|
|
payload: { message: "edit", vault: "V", mode: "general", conversation_history: [] },
|
|
};
|
|
b._messages = [msg];
|
|
|
|
await b._applyConfirmation(msg);
|
|
|
|
assert.equal(msg.confirmation, null, "confirmation resolved");
|
|
assert.ok(posted, "resumed via /agent");
|
|
assert.equal(posted.confirm.error.tool, "edit_file");
|
|
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
|
|
const cont = b._messages[b._messages.length - 1];
|
|
assert.equal(cont.content, "Fait.");
|
|
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
|
|
b._panel.remove();
|
|
localStorage.clear();
|
|
});
|
|
|
|
// ── 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 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();
|
|
});
|
|
|
|
// ── Summary ──
|
|
console.log(`\n${passCount}/${testCount} tests passed`);
|
|
if (passCount !== testCount) {
|
|
process.exit(1);
|
|
}}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|