feat: redesign UI section Cles API IA - recherche, carte defaut, cartes depliables, footer sticky (#104)
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#!/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(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<section id="cfg-ai" class="config-section">
|
||||
<input type="search" id="cfg-ai-search" />
|
||||
<select id="cfg-ai-default-provider"><option value="">--</option></select>
|
||||
<select id="cfg-ai-default-model"><option value="">--</option></select>
|
||||
<div id="cfg-ai-default-model-caps"></div>
|
||||
<div id="cfg-ai-providers" class="ai-providers-list"></div>
|
||||
<div id="cfg-ai-providers-empty" class="ai-providers-empty hidden"></div>
|
||||
<button id="cfg-save-ai-keys">Sauvegarder</button>
|
||||
<button id="cfg-test-ai-keys">Tester</button>
|
||||
<span id="cfg-ai-status"></span>
|
||||
</section>
|
||||
</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.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);
|
||||
}
|
||||
Reference in New Issue
Block a user