#!/usr/bin/env node /** * ObsiGate - JSDOM tests for the AI keys section redesign in the config * panel (#104): provider accordion cards, status badges, delete button, * provider search filter, save collection and capability badges. * * Usage: node tests/frontend/config-ai-keys.test.mjs */ import { strict as assert } from "node:assert"; import { JSDOM } from "jsdom"; import { readFileSync } from "node:fs"; 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"); const dom = new JSDOM( `
`, { url: "http://localhost/", pretendToBeVisual: true }, ); const w = dom.window; globalThis.window = w; globalThis.document = w.document; globalThis.HTMLElement = w.HTMLElement; globalThis.Element = w.Element; globalThis.Node = w.Node; globalThis.Event = w.Event; globalThis.CustomEvent = w.CustomEvent; globalThis.KeyboardEvent = w.KeyboardEvent; globalThis.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); let testCount = 0; let passCount = 0; async function test(name, fn) { testCount++; try { await fn(); passCount++; console.log(` ✓ ${name}`); } catch (err) { console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } // ── Fetch mocks ──────────────────────────────────────────────────────────── const POSTS = []; const MASKED_KEYS = { DEEPSEEK_API_KEY: "sk-…abcd", MISTRAL_API_KEY: "sk-…mist", }; // Real FR locale served for /static/locales/fr.json so t() assertions work. const FR_LOCALE = JSON.parse( readFileSync(path.resolve(JS_DIR, "..", "locales", "fr.json"), "utf8"), ); globalThis.fetch = async (url, opts = {}) => { const u = String(url); const method = (opts.method || "GET").toUpperCase(); if (method === "POST") POSTS.push({ url: u, body: opts.body || "" }); if (u.includes("/static/locales/fr.json")) { return { ok: true, status: 200, json: async () => FR_LOCALE }; } if (u.includes("/api/config/ai-keys/test")) { return { ok: true, status: 200, json: async () => ({}) }; } if (u.includes("/api/config/ai-keys")) { return { ok: true, status: 200, json: async () => MASKED_KEYS }; } if (u.endsWith("/api/config") && method === "GET") { return { ok: true, status: 200, json: async () => ({ ai_default_provider: "deepseek", ai_default_models: {} }), }; } if (u.includes("/api/config/ai-models")) { return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat", "deepseek-reasoner"] }), }; } if (u.includes("/api/ai/model-capabilities")) { return { ok: true, status: 200, json: async () => ({ capabilities: { chat: true, vision: true, embeddings: false } }), }; } return { ok: true, status: 200, json: async () => ({}) }; }; const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href); const aiMod = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href); const i18nMod = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); await i18nMod.initI18n(); const { loadAIKeys, saveAIKeys, filterAIProviders } = configMod; const { renderCapabilityBadges } = aiMod; const cards = () => Array.from(document.querySelectorAll("#cfg-ai-providers .ai-provider-card")); const visibleCards = () => cards().filter((c) => c.style.display !== "none"); console.log("AI keys section redesign (#104) — accordion, badges, filter, save"); await test("loadAIKeys renders one accordion card per provider", async () => { await loadAIKeys(); assert.equal(cards().length, 7, "seven provider cards rendered"); assert.equal( document.querySelector('[data-provider="deepseek"] .ai-provider-name').textContent, "DeepSeek", "display name rendered in the card header", ); assert.equal( document.querySelector('[data-provider="deepseek"] .ai-provider-logo').textContent, "D", "logo shows the provider initial", ); assert.ok( document.getElementById("cfg-deepseek-key") && document.getElementById("cfg-deepseek-model"), "key input and model select are rendered inside the card body", ); }); await test("status badges reflect the stored keys (header only)", async () => { const okBadge = document.getElementById("cfg-deepseek-badge"); const koBadge = document.getElementById("cfg-gemini-badge"); assert.ok(okBadge.textContent.includes("Configuré"), "configured label shown"); assert.ok(okBadge.classList.contains("configured"), "configured badge styled"); assert.ok(koBadge.textContent.includes("Non configuré"), "not-configured label shown"); assert.ok(!koBadge.classList.contains("configured"), "not-configured badge neutral"); const del = document.getElementById("cfg-deepseek-delete"); const delKo = document.getElementById("cfg-gemini-delete"); assert.equal(del.style.display, "inline-flex", "trash visible when configured"); assert.equal(delKo.style.display, "none", "trash hidden when not configured"); }); await test("clicking a card header toggles its body", async () => { const card = document.querySelector('[data-provider="gemini"]'); const head = card.querySelector(".ai-provider-head"); const body = card.querySelector(".ai-provider-body"); assert.ok(body.classList.contains("hidden"), "body collapsed by default"); head.dispatchEvent(new w.Event("click", { bubbles: true })); assert.ok(!body.classList.contains("hidden"), "body expanded after click"); assert.equal(card.classList.contains("open"), true, "card flagged open"); assert.equal(head.getAttribute("aria-expanded"), "true", "aria-expanded synced"); head.dispatchEvent(new w.Event("click", { bubbles: true })); assert.ok(body.classList.contains("hidden"), "body collapsed again"); }); await test("filterAIProviders narrows cards case/accent-insensitively", async () => { filterAIProviders("MISTRAL"); assert.equal(visibleCards().length, 1, "one card matches 'MISTRAL'"); filterAIProviders("qwencloud"); assert.equal(visibleCards().length, 1, "one card matches 'qwencloud'"); filterAIProviders("zzz-inexistant"); assert.equal(visibleCards().length, 0, "no card matches"); assert.ok( !document.getElementById("cfg-ai-providers-empty").classList.contains("hidden"), "empty-state message shown when nothing matches", ); filterAIProviders(""); assert.equal(visibleCards().length, 7, "clearing restores all cards"); assert.ok( document.getElementById("cfg-ai-providers-empty").classList.contains("hidden"), "empty-state message hidden again", ); }); await test("saveAIKeys collects typed keys and posts them", async () => { POSTS.length = 0; const geminiInput = document.getElementById("cfg-gemini-key"); geminiInput.value = " test-gemini-key "; await saveAIKeys(); const aiPost = POSTS.find((p) => p.url.includes("/api/config/ai-keys") && p.url.includes("/test") === false); assert.ok(aiPost, "a POST was sent to /api/config/ai-keys"); const sent = JSON.parse(aiPost.body); assert.equal(sent.GEMINI_API_KEY, "test-gemini-key", "trimmed key sent under its env name"); assert.ok(!sent.DEEPSEEK_API_KEY, "empty inputs are not re-sent"); }); await test("delete button calls deleteAIKey with confirmation", async () => { let deleted = null; let confirmed = false; globalThis.confirm = () => { confirmed = true; return true; }; const origFetch = globalThis.fetch; globalThis.fetch = async (url, opts = {}) => { const u = String(url); if ((opts.method || "").toUpperCase() === "DELETE" && u.includes("/api/config/ai-keys/")) { deleted = u; return { ok: true, status: 200, json: async () => ({}) }; } return origFetch(url, opts); }; document.getElementById("cfg-deepseek-delete").click(); assert.ok(confirmed, "a confirmation was requested"); assert.ok(deleted && deleted.includes("DEEPSEEK_API_KEY"), "DELETE sent for the provider env key"); globalThis.fetch = origFetch; }); await test("renderCapabilityBadges renders only enabled capabilities", () => { const box = renderCapabilityBadges({ chat: true, vision: true, embeddings: false }); const badges = Array.from(box.querySelectorAll(".ai-cap-badge")); assert.equal(badges.length, 2, "only enabled capabilities rendered"); assert.equal(box.className, "ai-caps-badges", "badge box class applied"); }); // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { process.exit(1); }