Files
ObsiGate/tests/frontend/ai.test.mjs
T
bruno d6fdcef4d4
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)

Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible

Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
  'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne

Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
2026-09-11 09:11:00 -04:00

375 lines
15 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 } = 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(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();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}}
main().catch((e) => {
console.error(e);
process.exit(1);
});