feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
#78 Excalidraw — finitions B5/C8/F2/F3 - backend/indexer.py: port Python pur de lz-string decompressFromBase64 (bitsPerChar=6, resetValue=32) validé contre 4 fixtures JS truth (texte accentué, edge cases) — remplace le stub base64 non-fonctionnel - B5 indexation: .excalidraw.md (format plugin Obsidian) maintenant décompressé côté Python → texte indexé pour recherche TF-IDF - 7 nouveaux tests B5 dans tests/test_excalidraw.py (13/13 total) - F2: excalidraw-viewer.test.mjs enregistré dans CI (lint job) - F3: tests/e2e/excalidraw.spec.js (9 specs — ouverture .excalidraw/.excalidraw.md, création via modale/menu contextuel, toolbar, thème, pop-out, recherche) - Fixtures test_vault/diagram.excalidraw + diagram.excalidraw.md #67 Push notifications (Web Push API + VAPID) - backend/push.py: router + VAPID keys + send_push_notification (pywebpush>=2.3.0) - frontend/js/push.js: subscription UI + service worker integration - tests/test_push.py: 10 tests (subscribe/list/unsubscribe/vapid key) - requirements.txt + sw.js + locales push strings #68 Health check enrichi - backend/main.py: GET /api/health/detailed (admin) — memory/cpu/disk/backups/index/SSE connections - backend/indexer.py: _last_full_index_ts tracking - tests/conftest.py: admin_client fixture - tests/test_api_main.py: 3 nouveaux tests health detailed #77 Desktop jumplist - desktop/src/jumplist.rs: Windows jumplist integration - Cargo.toml/lock + capabilities + main.rs - frontend/js/desktop.js: Tauri bridge (isTauriEnv, invoke, getSystemTheme, syncSystemTheme, shouldShowWizard, crash banner) - tests/frontend/desktop.test.mjs: 21 tests JSDOM (détection, invoke degradation, theme gating, wizard, crash banner) - tests/frontend/unit.test.mjs: desktop.js whitelisted (standalone global reader) # Frontend & CI - frontend/sw.js: réécriture complète (precache + push event handlers + offline) - frontend/index.html: section push dans settings + about repositionné - frontend/js/app.js: initDesktopIntegration + initPush - CI .gitea/workflows/ci.yml: excalidraw-viewer.test.mjs ajouté au lint job All checks: ruff clean, 552 backend tests pass, 9 frontend unit, 5 excalidraw-viewer, 21 desktop, 9 pane-manager, validate-imports 33 modules.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Desktop bridge (desktop.js) unit tests.
|
||||
*
|
||||
* Tests the pure logic of the Tauri bridge: env detection, invoke degradation,
|
||||
* OS theme sync gating, and the first-run wizard decision. No real Tauri needed.
|
||||
*
|
||||
* Usage: node tests/frontend/desktop.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 REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body></body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "desktop.js")).href;
|
||||
const desktop = await import(moduleUrl);
|
||||
|
||||
let passed = 0;
|
||||
function ok(name, cond) {
|
||||
assert.ok(cond, name);
|
||||
passed++;
|
||||
console.log(" ✓ " + name);
|
||||
}
|
||||
|
||||
// ── Detection ──────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("detection");
|
||||
ok("isTauriEnv false when no __TAURI__", desktop.isTauriEnv() === false);
|
||||
|
||||
window.__TAURI__ = { core: { invoke: () => {} } };
|
||||
ok("isTauriEnv true when __TAURI__ present", desktop.isTauriEnv() === true);
|
||||
|
||||
// ── invoke degradation ──────────────────────────────────────────────────────
|
||||
|
||||
console.log("invoke");
|
||||
{
|
||||
// Web env: invoke resolves undefined, never throws
|
||||
delete window.__TAURI__;
|
||||
const r = await desktop.invoke("get_version");
|
||||
ok("invoke resolves undefined on web", r === undefined);
|
||||
}
|
||||
|
||||
{
|
||||
// Tauri env with working core.invoke
|
||||
window.__TAURI__ = { core: { invoke: async (cmd) => "called:" + cmd } };
|
||||
const r = await desktop.invoke("get_version");
|
||||
ok("invoke forwards to core.invoke", r === "called:get_version");
|
||||
}
|
||||
|
||||
{
|
||||
// Tauri env but invoke throws → resolves undefined, no crash
|
||||
window.__TAURI__ = { core: { invoke: async () => { throw new Error("boom"); } } };
|
||||
const r = await desktop.invoke("get_version");
|
||||
ok("invoke swallows IPC errors", r === undefined);
|
||||
}
|
||||
|
||||
// ── getSystemTheme ──────────────────────────────────────────────────────────
|
||||
|
||||
console.log("getSystemTheme");
|
||||
{
|
||||
window.__TAURI__ = { core: { invoke: async () => "dark" } };
|
||||
ok("getSystemTheme returns Tauri theme", (await desktop.getSystemTheme()) === "dark");
|
||||
|
||||
window.__TAURI__ = { core: { invoke: async () => undefined } };
|
||||
const t = await desktop.getSystemTheme();
|
||||
ok("getSystemTheme falls back to light/dark", t === "light" || t === "dark");
|
||||
}
|
||||
|
||||
// ── hasExplicitThemeMode / syncSystemTheme ──────────────────────────────────
|
||||
|
||||
console.log("theme gating");
|
||||
localStorage.clear();
|
||||
ok("no explicit mode initially", desktop.hasExplicitThemeMode() === false);
|
||||
|
||||
// Web env: syncSystemTheme is a no-op (does not touch localStorage)
|
||||
delete window.__TAURI__;
|
||||
await desktop.syncSystemTheme();
|
||||
ok("syncSystemTheme no-op on web", localStorage.getItem("obsigate-theme-mode") === null);
|
||||
|
||||
// Tauri env + no explicit mode → follows OS theme
|
||||
window.__TAURI__ = { core: { invoke: async () => "dark" } };
|
||||
await desktop.syncSystemTheme();
|
||||
ok("syncSystemTheme sets OS mode on desktop first-run", localStorage.getItem("obsigate-theme-mode") === "dark");
|
||||
|
||||
// Tauri env + explicit mode → never overrides
|
||||
localStorage.setItem("obsigate-theme-mode", "light");
|
||||
window.__TAURI__ = { core: { invoke: async () => "dark" } };
|
||||
await desktop.syncSystemTheme();
|
||||
ok("syncSystemTheme respects explicit mode", localStorage.getItem("obsigate-theme-mode") === "light");
|
||||
|
||||
// High-contrast / sepia are never overridden even with no explicit flag check
|
||||
localStorage.removeItem("obsigate-theme-mode");
|
||||
localStorage.setItem("obsigate-theme-mode", "high-contrast");
|
||||
await desktop.syncSystemTheme();
|
||||
ok("syncSystemTheme never overrides high-contrast", localStorage.getItem("obsigate-theme-mode") === "high-contrast");
|
||||
|
||||
// ── Wizard decision ─────────────────────────────────────────────────────────
|
||||
|
||||
console.log("wizard");
|
||||
localStorage.clear();
|
||||
window.__TAURI__ = { core: { invoke: async () => "dark" } };
|
||||
|
||||
ok("shouldShowWizard: desktop, not dismissed, no vault → true",
|
||||
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: false }) === true);
|
||||
ok("shouldShowWizard: dismissed → false",
|
||||
desktop.shouldShowWizard({ isDesktop: true, dismissed: true, hasVaultPath: false }) === false);
|
||||
ok("shouldShowWizard: has vault path → false",
|
||||
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: true }) === false);
|
||||
ok("shouldShowWizard: web → false",
|
||||
desktop.shouldShowWizard({ isDesktop: false, dismissed: false, hasVaultPath: false }) === false);
|
||||
|
||||
// dismissWizard persists the flag
|
||||
desktop.dismissWizard();
|
||||
ok("dismissWizard persists flag", localStorage.getItem(desktop.getWizardDismissedKey()) === "1");
|
||||
ok("shouldShowWizard reflects dismissed flag via localStorage",
|
||||
desktop.shouldShowWizard({ isDesktop: true, hasVaultPath: false }) === false);
|
||||
|
||||
// ── crash banner ────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("crash banner");
|
||||
{
|
||||
delete window.__TAURI__;
|
||||
desktop.defineBackendCrashBanner();
|
||||
ok("defines window.showBackendCrashBanner", typeof window.showBackendCrashBanner === "function");
|
||||
window.showBackendCrashBanner();
|
||||
ok("crash banner injects DOM node", document.getElementById("backend-crash-banner") !== null);
|
||||
window.showBackendCrashBanner(); // idempotent — no duplicate
|
||||
ok("crash banner idempotent", document.querySelectorAll("#backend-crash-banner").length === 1);
|
||||
}
|
||||
|
||||
console.log("\n✅ " + passed + " tests passés");
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM integration tests for Excalidraw Viewer (ROADMAP #78 F2).
|
||||
*
|
||||
* Loads the actual excalidraw-viewer.js ES module in a JSDOM environment and
|
||||
* verifies iframe creation, the postMessage init flow, and export helpers.
|
||||
*
|
||||
* Usage: node tests/frontend/excalidraw-viewer.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 REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="content-area"></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.MessageEvent = w.MessageEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
// The viewer calls addEventListener on the iframe contentWindow's source check.
|
||||
// We stub window.postMessage (jsdom doesn't dispatch it).
|
||||
w.HTMLIFrameElement.prototype.postMessage = () => {};
|
||||
// jsdom may not initialize iframe.sandbox for created elements; we define it
|
||||
// lazily so the viewer's sandbox.add() calls work.
|
||||
Object.defineProperty(w.HTMLIFrameElement.prototype, "sandbox", {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!this._sandboxList) {
|
||||
this._sandboxList = new Set();
|
||||
const list = this._sandboxList;
|
||||
this._sandboxListAdd = (token) => {
|
||||
list.add(token);
|
||||
};
|
||||
}
|
||||
return {
|
||||
add: (token) => this._sandboxListAdd(token),
|
||||
contains: (token) => this._sandboxList.has(token),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dynamic import (after globals are set) ─────────────────────────────────
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "excalidraw-viewer.js")).href
|
||||
);
|
||||
|
||||
const { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor } = mod;
|
||||
|
||||
// ── Test suite ──────────────────────────────────────────────────────────────
|
||||
console.log("\n── excalidraw-viewer.js JSDOM integration tests (#78 F2) ──\n");
|
||||
|
||||
await test("module exports renderExcalidraw + helpers", () => {
|
||||
assert.equal(typeof renderExcalidraw, "function");
|
||||
assert.equal(typeof notifyExcalidrawThemeChange, "function");
|
||||
assert.equal(typeof destroyExcalidrawEditor, "function");
|
||||
});
|
||||
|
||||
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
|
||||
const container = document.getElementById("content-area");
|
||||
const data = {
|
||||
is_excalidraw: true,
|
||||
vault: "TestVault",
|
||||
path: "diagram.excalidraw",
|
||||
excalidraw_data: { elements: [], appState: {}, files: {} },
|
||||
};
|
||||
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
|
||||
const iframe = container.querySelector("iframe");
|
||||
assert.ok(iframe, "iframe should be created");
|
||||
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
|
||||
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
|
||||
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
|
||||
assert.match(iframe.style.cssText, /100%/);
|
||||
});
|
||||
|
||||
await test("renderExcalidraw accepts compressed .excalidraw.md data", () => {
|
||||
const container = document.getElementById("content-area");
|
||||
const data = {
|
||||
is_excalidraw: true,
|
||||
vault: "V",
|
||||
path: "diagram.excalidraw.md",
|
||||
excalidraw_data_compressed: "dummy",
|
||||
};
|
||||
// Should not throw when building with compressed payload
|
||||
renderExcalidraw(container, data, "V", "diagram.excalidraw.md");
|
||||
const iframe = container.querySelector("iframe");
|
||||
assert.ok(iframe, "iframe created for .excalidraw.md");
|
||||
});
|
||||
|
||||
await test("notifyExcalidrawThemeChange does not throw with no editors", () => {
|
||||
notifyExcalidrawThemeChange("dark");
|
||||
});
|
||||
|
||||
await test("destroyExcalidrawEditor is idempotent (no-op for unknown id)", () => {
|
||||
destroyExcalidrawEditor("excalidraw-999");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
@@ -108,8 +108,9 @@ function testModulesHaveImports() {
|
||||
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
|
||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
||||
{ file: 'themes.js', reason: 'standalone — no deps' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
];
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
];
|
||||
const expectNoImport = new Set(expected.map(e => e.file));
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
Reference in New Issue
Block a user