diff --git a/backend/main.py b/backend/main.py
index bb8f117..44e0c16 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
- from backend.draco_config import agent_origine
+ from backend.draco_config import agent_origine, origine, urls_publiques
agent = agent_origine()
+ # Fusion #69 : le panneau Alertes unifiées lit les API
+ # notifications des applications soeurs depuis cet hôte ;
+ # leurs origines rejoignent connect-src (même résolution
+ # draco_config que les liens de la barre : explicite >
+ # dérivation depuis l'hôte de l'agent > défaut officiel).
+ _soeurs69 = " ".join(sorted({
+ origine(v)
+ for k, v in urls_publiques().items()
+ if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
+ } - {""}))
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
- f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
+ f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
diff --git a/frontend/draco-alertes.css b/frontend/draco-alertes.css
new file mode 100644
index 0000000..0d0582e
--- /dev/null
+++ b/frontend/draco-alertes.css
@@ -0,0 +1,39 @@
+/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
+ * d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
+.draco-alertes-overlay {
+ position: fixed; inset: 0; z-index: 30000;
+ display: none; align-items: flex-start; justify-content: center;
+ padding: 8vh 16px 16px;
+ background: rgb(0 0 0 / .55); overflow-y: auto;
+}
+.draco-alertes-box {
+ width: 720px; max-width: 100%;
+ background: var(--bg-primary, #1f1f1f);
+ color: var(--text, #eceee9);
+ border: 1px solid var(--border, #333);
+ border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
+ padding: 16px 18px 18px;
+}
+.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.draco-alertes-head h2 { margin: 0; font-size: 18px; }
+.draco-alertes-close {
+ border: 1px solid var(--border, #333); background: transparent;
+ color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
+}
+.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
+.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
+.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
+.draco-alertes-item {
+ border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
+ padding: 9px 12px; margin: 6px 0;
+}
+.draco-alertes-item.is-active { border-color: #e8762c; }
+.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
+.draco-alertes-title:hover { text-decoration: underline; }
+.draco-alertes-detail {
+ font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
+.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
+.draco-alertes-empty a { color: inherit; }
diff --git a/frontend/draco-alertes.js b/frontend/draco-alertes.js
new file mode 100644
index 0000000..b2aed2c
--- /dev/null
+++ b/frontend/draco-alertes.js
@@ -0,0 +1,427 @@
+/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
+ *
+ * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
+ * ouverture du panneau (et au chargement, pour la pastille), le module
+ * interroge les API de notifications réellement exposées par chaque
+ * application, avec les identifiants du navigateur (credentials:
+ * "include"). Aucune alerte n'est inventée : une source injoignable
+ * ou non authentifiée affiche son état réel (« Connexion requise »)
+ * avec un lien profond vers sa page native, jamais du vide déguisé.
+ *
+ * Sources réelles (audit #69, 2026-10-28) :
+ * - Flowdeck : GET {base}/api/notifications?limit=50
+ * -> { notifications: [{id, ntype, title, message, url, is_read,
+ * created_at}], unread } ; active = is_read = 0 ; lien = base + url.
+ * - TikTak : GET {base}/api/notifications
+ * -> [{id, kind, title, body, task_id, is_read, created_at}] ;
+ * active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
+ * base + "/notifications".
+ * - Kanbante : GET {base}/api/events?limit=100
+ * -> { events: [{repo_full_name, issue_number, event_type,
+ * from_column, to_column, created_at}] } ; AUCUN état lu/non lu
+ * n'existe dans Kanbante, donc tout y est historique et rien n'y
+ * est compté comme actif ; lien = base + "/board/" + repo.
+ * - ObsiGate : AUCUNE notification entrante consultable. Ses
+ * /api/notify/* sont des canaux d'envoi sortants (admin, secrets
+ * masqués), pas un journal lisible par l'utilisateur. La section
+ * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
+ *
+ * Résolution des bases (même ordre que barre-apps v0.1.6) :
+ * 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
+ * window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
+ * 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
+ * 3. href rendu de l'onglet .draco-apptab[data-app] ;
+ * 4. défaut officiel (production).
+ * ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
+ * lit aussi window.DRACO_URLS (injecté par son backend).
+ * Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
+ * que sur un hôte -dev, exactement comme la barre.
+ *
+ * Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
+ * seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
+ * pour la pastille (repli : mise à jour directe du badge).
+ * Insertion des données : textContent uniquement, jamais innerHTML
+ * avec une valeur venue du serveur.
+ */
+(function (global) {
+ "use strict";
+
+ var DEFAULT_URLS = {
+ obsigate: "https://og.dracodev.net/",
+ flowdeck: "https://flowdeck.dracodev.net/",
+ tiktak: "https://tiktak.dracodev.net/",
+ kanbante: "https://kanbante.dracodev.net/"
+ };
+ var DEV_URLS = {
+ obsigate: "https://og-dev.dracodev.net/",
+ flowdeck: "https://flowdeck-dev.dracodev.net/",
+ tiktak: "https://tiktak-dev.dracodev.net/",
+ kanbante: "https://kanbante-dev.dracodev.net/"
+ };
+ var APP_LABELS = {
+ obsigate: "ObsiGate", flowdeck: "Flowdeck",
+ tiktak: "TikTak", kanbante: "Kanbante"
+ };
+ var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
+ var EVENT_LABELS = {
+ created: "créée", moved: "déplacée", closed: "fermée",
+ reopened: "rouverte", commented: "commentée"
+ };
+
+ function isDevHost(hostname) {
+ if (!hostname) return false;
+ var h = String(hostname).toLowerCase();
+ if (h.slice(-13) !== ".dracodev.net") return false;
+ var first = h.split(".")[0];
+ return first.length > 4 && first.slice(-4) === "-dev";
+ }
+ function cleanUrl(u) {
+ if (!u || typeof u !== "string") return null;
+ if (u.indexOf("{{") !== -1) return null;
+ if (!u.replace(/\s/g, "")) return null;
+ return u;
+ }
+ function withSlash(u) {
+ return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
+ }
+ function joinUrl(base, path) {
+ var b = withSlash(base);
+ if (!path) return b;
+ if (/^https?:\/\//.test(path)) return path;
+ return b + String(path).replace(/^\//, "");
+ }
+ function parseDate(s) {
+ if (!s) return null;
+ var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
+ ? String(s).replace(" ", "T") + "Z" : s);
+ return isNaN(t.getTime()) ? null : t;
+ }
+ function fmtDate(s) {
+ var t = parseDate(s);
+ if (!t) return s ? String(s).slice(0, 16) : "";
+ try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
+ }
+
+ /* Résolution des bases. `env` permet les tests sans navigateur :
+ * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
+ function resolveUrls(env) {
+ env = env || {};
+ var hostname = env.hostname;
+ if (hostname === undefined) {
+ try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
+ }
+ var dev = isDevHost(hostname);
+ var urls = {};
+ APP_ORDER.forEach(function (app) {
+ var u = cleanUrl(env.barUrls && env.barUrls[app])
+ || cleanUrl(env.configUrls && env.configUrls[app]);
+ if (!u && dev) u = DEV_URLS[app] || null;
+ if (!u) {
+ var href = null;
+ if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
+ else if (typeof document !== "undefined" && document.querySelector) {
+ var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
+ href = tab ? tab.getAttribute("href") : null;
+ }
+ u = cleanUrl(href);
+ }
+ if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
+ if (!u) {
+ /* ObsiGate : window.DRACO_URLS injecté par le backend. */
+ try {
+ if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
+ } catch (e) { /* rien */ }
+ }
+ if (!u) u = DEFAULT_URLS[app];
+ urls[app] = withSlash(u);
+ });
+ return urls;
+ }
+ function browserEnv() {
+ var cfg = global.DRACO_BAR_CONFIG || {};
+ var bar = global.DRACO_BAR || {};
+ var tabs = {};
+ try {
+ if (typeof document !== "undefined" && document.querySelectorAll) {
+ var nodes = document.querySelectorAll(".draco-apptab[data-app]");
+ for (var i = 0; i < nodes.length; i++) {
+ tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
+ }
+ }
+ } catch (e) { /* rien */ }
+ return {
+ barUrls: bar.urls || null, configUrls: cfg.urls || null,
+ tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
+ };
+ }
+
+ function normalizeFlowdeck(base, payload) {
+ var list = (payload && payload.notifications) || [];
+ return list.map(function (n) {
+ return {
+ app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
+ detail: n.message || "", date: n.created_at || "",
+ active: !n.is_read, state: n.is_read ? "lue" : "active",
+ kind: n.ntype || "", url: joinUrl(base, n.url || "")
+ };
+ });
+ }
+ function normalizeTiktak(base, payload) {
+ var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
+ return list.map(function (n) {
+ return {
+ app: "tiktak", id: n.id, title: n.title || "(sans titre)",
+ detail: n.body || "", date: n.created_at || "",
+ active: !n.is_read, state: n.is_read ? "lue" : "active",
+ kind: n.kind || "",
+ url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
+ };
+ });
+ }
+ function normalizeKanbante(base, payload) {
+ var list = (payload && payload.events) || [];
+ return list.map(function (e) {
+ var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
+ var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
+ return {
+ app: "kanbante", id: e.id,
+ title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
+ detail: detail, date: e.created_at || "",
+ active: false, state: label, kind: e.event_type || "",
+ url: joinUrl(base, "board/" + (e.repo_full_name || ""))
+ };
+ });
+ }
+ /* Actives d'abord, puis date décroissante (sans date en dernier). */
+ function sortAlerts(list) {
+ return list.slice().sort(function (a, b) {
+ if (a.active !== b.active) return a.active ? -1 : 1;
+ var da = parseDate(a.date), db = parseDate(b.date);
+ if (da && db) return db - da;
+ if (da) return -1;
+ if (db) return 1;
+ return 0;
+ });
+ }
+ function countActive(list) {
+ var n = 0;
+ list.forEach(function (a) { if (a.active) n++; });
+ return n;
+ }
+
+ /* ---- Rendu (navigateur seulement) ---- */
+ var panel = null, listEl = null, badgeTimer = null;
+
+ function el(tag, cls, text) {
+ var d = document.createElement(tag);
+ if (cls) d.className = cls;
+ if (text !== undefined && text !== null) d.textContent = text;
+ return d;
+ }
+ function ensurePanel() {
+ if (panel) return panel;
+ var ov = el("div", "draco-alertes-overlay");
+ ov.setAttribute("role", "dialog");
+ ov.setAttribute("aria-label", "Alertes unifiées");
+ var box = el("div", "draco-alertes-box");
+ var head = el("div", "draco-alertes-head");
+ head.appendChild(el("h2", "", "Alertes unifiées"));
+ var note = el("p", "draco-alertes-note",
+ "Agrégation à la demande, pas une fédération temps réel : " +
+ "les alertes sont relues dans chaque application à l'ouverture.");
+ var close = el("button", "draco-alertes-close", "✕");
+ close.setAttribute("aria-label", "Fermer");
+ close.addEventListener("click", closePanel);
+ head.appendChild(close);
+ box.appendChild(head);
+ box.appendChild(note);
+ listEl = el("div", "draco-alertes-list");
+ box.appendChild(listEl);
+ ov.appendChild(box);
+ ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
+ document.addEventListener("keydown", function (ev) {
+ if (ev.key === "Escape") closePanel();
+ });
+ document.body.appendChild(ov);
+ panel = ov;
+ return ov;
+ }
+ function closePanel() { if (panel) panel.style.display = "none"; }
+
+ function renderSection(title, items, fallback) {
+ var sec = el("section", "draco-alertes-section");
+ sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
+ if (!items.length) {
+ sec.appendChild(el("p", "draco-alertes-empty", fallback));
+ return sec;
+ }
+ items.forEach(function (a) {
+ var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
+ var main = el("div", "draco-alertes-main");
+ var link = el("a", "draco-alertes-title", a.title);
+ link.href = a.url;
+ main.appendChild(link);
+ if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
+ var meta = el("div", "draco-alertes-meta",
+ APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
+ " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
+ main.appendChild(meta);
+ row.appendChild(main);
+ sec.appendChild(row);
+ });
+ return sec;
+ }
+ function render(results, urls) {
+ ensurePanel();
+ listEl.textContent = "";
+ var all = [];
+ APP_ORDER.forEach(function (app) {
+ (results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
+ });
+ var actives = sortAlerts(all.filter(function (a) { return a.active; }));
+ var history = sortAlerts(all.filter(function (a) { return !a.active; }));
+ listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
+ APP_ORDER.forEach(function (app) {
+ var items = actives.filter(function (a) { return a.app === app; });
+ if (results[app] && results[app].error) {
+ var sec = renderSection(APP_LABELS[app], [], results[app].error);
+ listEl.appendChild(sec);
+ } else if (app === "obsigate") {
+ listEl.appendChild(renderSection(APP_LABELS[app], [],
+ "ObsiGate n'expose aucune notification entrante consultable " +
+ "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
+ } else if (items.length) {
+ listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
+ }
+ });
+ if (!actives.length) {
+ listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
+ }
+ listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
+ APP_ORDER.forEach(function (app) {
+ if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
+ if (app === "obsigate") {
+ var link = el("div", "draco-alertes-empty");
+ var a = el("a", "", "Ouvrir ObsiGate");
+ a.href = urls.obsigate;
+ link.appendChild(a);
+ var sec = el("section", "draco-alertes-section");
+ sec.appendChild(el("h3", "", "ObsiGate (0)"));
+ sec.appendChild(link);
+ listEl.appendChild(sec);
+ return;
+ }
+ var items = history.filter(function (x) { return x.app === app; });
+ listEl.appendChild(renderSection(APP_LABELS[app], items,
+ "Aucun historique disponible pour cette application."));
+ });
+ }
+
+ function fetchJson(url, timeoutMs) {
+ var ctrl = null, timer = null;
+ try {
+ if (typeof AbortController !== "undefined") {
+ ctrl = new AbortController();
+ timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
+ }
+ } catch (e) { ctrl = null; }
+ var opts = { credentials: "include" };
+ if (ctrl) opts.signal = ctrl.signal;
+ return fetch(url, opts).then(function (r) {
+ if (timer) clearTimeout(timer);
+ if (!r.ok) {
+ var err = new Error("HTTP " + r.status);
+ err.status = r.status;
+ throw err;
+ }
+ return r.json();
+ }).catch(function (e) {
+ if (timer) clearTimeout(timer);
+ throw e;
+ });
+ }
+ function loadAll() {
+ var urls = resolveUrls(browserEnv());
+ var jobs = {
+ flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
+ .then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
+ tiktak: fetchJson(urls.tiktak + "api/notifications")
+ .then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
+ kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
+ .then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
+ };
+ var keys = Object.keys(jobs);
+ return Promise.all(keys.map(function (k) {
+ return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
+ var msg = (e && e.status === 401)
+ ? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
+ : "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
+ return [k, { items: [], error: msg }];
+ });
+ })).then(function (pairs) {
+ var results = { obsigate: { items: [] } };
+ pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
+ return { urls: urls, results: results };
+ });
+ }
+ function setBadge(n) {
+ try {
+ if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
+ global.DRACO_BAR.setCounts({ alertes: n });
+ return;
+ }
+ } catch (e) { /* repli ci-dessous */ }
+ try {
+ document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
+ b.textContent = n;
+ b.hidden = !(n > 0);
+ });
+ } catch (e) { /* rien */ }
+ }
+ function refreshBadge() {
+ return loadAll().then(function (r) {
+ var all = [];
+ APP_ORDER.forEach(function (app) {
+ (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
+ });
+ setBadge(countActive(all));
+ return countActive(all);
+ }).catch(function () { /* pastille optionnelle : la barre vit sans */ });
+ }
+ function openPanel() {
+ ensurePanel();
+ panel.style.display = "flex";
+ listEl.textContent = "";
+ listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
+ loadAll().then(function (r) {
+ render(r.results, r.urls);
+ var all = [];
+ APP_ORDER.forEach(function (app) {
+ (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
+ });
+ setBadge(countActive(all));
+ }).catch(function () {
+ render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
+ });
+ }
+
+ var api = {
+ open: openPanel, close: closePanel, refreshBadge: refreshBadge,
+ _test: {
+ resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
+ normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
+ sortAlerts: sortAlerts, countActive: countActive,
+ isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
+ }
+ };
+ global.DRACO_ALERTES = api;
+ if (typeof module !== "undefined" && module.exports) module.exports = api._test;
+
+ /* Pastille au chargement (après les scripts defer de la barre). */
+ if (typeof document !== "undefined" && document.addEventListener) {
+ document.addEventListener("DOMContentLoaded", function () {
+ if (badgeTimer) clearTimeout(badgeTimer);
+ badgeTimer = setTimeout(refreshBadge, 300);
+ });
+ }
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/frontend/index.html b/frontend/index.html
index 6e03be8..d4da66e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -76,6 +76,7 @@
+
+
diff --git a/tests/frontend/draco-alertes.test.mjs b/tests/frontend/draco-alertes.test.mjs
new file mode 100644
index 0000000..53684a7
--- /dev/null
+++ b/tests/frontend/draco-alertes.test.mjs
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+/**
+ * ObsiGate — Alertes unifiées (Fusion #69, chantier 2).
+ *
+ * Tests du module partagé frontend/draco-alertes.js (logique pure,
+ * sans DOM) + vérification du gabarit livré frontend/index.html :
+ * le placeholder « Alertes unifiées : arrive avec la fédération »
+ * a disparu et le module est chargé.
+ *
+ * Usage: node tests/frontend/draco-alertes.test.mjs
+ */
+import test from "node:test";
+import { strict as assert } from "node:assert";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+const require = createRequire(import.meta.url);
+const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
+
+test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
+ assert.equal(
+ t.resolveUrls({ hostname: "og.dracodev.net", tabHrefs: {} }).tiktak,
+ "https://tiktak.dracodev.net/"
+ );
+ assert.equal(
+ t.resolveUrls({ hostname: "og-dev.dracodev.net", tabHrefs: {} }).flowdeck,
+ "https://flowdeck-dev.dracodev.net/"
+ );
+ assert.equal(
+ t.resolveUrls({
+ hostname: "og-dev.dracodev.net",
+ configUrls: { kanbante: "https://kanbante.dracodev.net/" },
+ }).kanbante,
+ "https://kanbante.dracodev.net/"
+ );
+ assert.equal(t.isDevHost("og-dev.dracodev.net.evil.com"), false);
+});
+
+test("normalizeFlowdeck/TikTak/Kanbante: actifs = non lus, Kanbante jamais actif", () => {
+ const fd = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
+ notifications: [
+ { id: 1, ntype: "mention", title: "Mention", message: "m", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
+ ],
+ unread: 1,
+ });
+ assert.equal(fd[0].active, true);
+ assert.equal(fd[0].url, "https://flowdeck.dracodev.net/collections/3");
+ const tt = t.normalizeTiktak("https://tiktak.dracodev.net/", [
+ { id: 7, kind: "reminder", title: "Rappel", body: "", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
+ ]);
+ assert.equal(tt[0].url, "https://tiktak.dracodev.net/?task=42");
+ const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
+ events: [
+ { id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
+ ],
+ });
+ assert.equal(kb[0].active, false);
+ assert.equal(kb[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
+ assert.equal(t.countActive([...fd, ...tt, ...kb]), 2);
+});
+
+test("sortAlerts: actives d'abord, date decroissante", () => {
+ const sorted = t.sortAlerts([
+ { app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
+ { app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
+ { app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
+ ]);
+ assert.equal(sorted[0].active, true);
+ assert.equal(sorted[0].date, "2026-10-28T12:00:00");
+});
+
+test("index.html: placeholder Alertes disparu, module charge", () => {
+ const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
+ assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
+ assert.equal(html.includes("/static/draco-alertes.js"), true);
+ assert.equal(html.includes("/static/draco-alertes.css"), true);
+ assert.equal(html.includes("DRACO_ALERTES.open()"), true);
+});