#!/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"; import fs from "node:fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, "..", ".."); // ── JSDOM bootstrap ───────────────────────────────────────────────────────── 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.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"); }); // ── Static regression guards (BUG-064) ────────────────────────────────────── const editorHtml = fs.readFileSync( path.join(REPO_ROOT, "frontend", "excalidraw-editor.html"), "utf8" ); const mainPy = fs.readFileSync(path.join(REPO_ROOT, "backend", "main.py"), "utf8"); const styleCss = fs.readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8"); const viewerJs = fs.readFileSync( path.join(REPO_ROOT, "frontend", "js", "excalidraw-viewer.js"), "utf8" ); await test("excalidraw-editor.html loads the Excalidraw stylesheet", () => { // Without it the editor is unstyled AND the container enters a resize // feedback loop up to the 2^25 canvas cap → blank scene. assert.match( editorHtml, /https:\/\/esm\.sh\/@excalidraw\/excalidraw@[\d.]+\/dist\/prod\/index\.css/, "the Excalidraw CSS must be linked from esm.sh" ); }); await test("backend CSP style-src allows esm.sh (Excalidraw stylesheet)", () => { const csp = mainPy.match(/style-src ([^";]+);/); assert.ok(csp, "CSP style-src directive not found"); assert.ok( csp[1].includes("https://esm.sh"), `style-src must allow https://esm.sh (found: ${csp[1]})` ); }); await test("excalidraw-editor.html sanitizes appState (collaborators + viewport geometry)", () => { assert.match(editorHtml, /function sanitizeAppState/, "sanitizeAppState helper missing"); assert.match(editorHtml, /collaborators instanceof Map/, "collaborators must be restored as a Map"); assert.match( editorHtml, /"width", "height", "offsetLeft", "offsetTop"/, "container-derived viewport geometry must be dropped" ); }); await test("style.css — Excalidraw goes full width when the sidebar is hidden", () => { // Mirrors the PDF/image full-bleed rule: the centered 1200px reading column // must not apply to the Excalidraw viewer. const rule = styleCss.match( /\.content-area:has\(iframe\[src\*="excalidraw-editor\.html"\]\)\s*\{([^}]*)\}/ ); assert.ok(rule, "Excalidraw full-bleed rule not found"); assert.match(rule[1], /max-width:\s*none/, "the 1200px reading cap must be lifted"); assert.match(rule[1], /margin:\s*0/, "the centered margin must be removed"); }); await test("excalidraw-viewer.js has no autosave (no requestSave)", () => { // Autosave writes the file, which emits `index_updated` and reloads the // viewer — a visible page refresh that also interrupts drawing. Saves are // explicit (in-editor Save button / Ctrl+S) only. assert.doesNotMatch(viewerJs, /requestSave/, "the viewer must not request automatic saves"); assert.doesNotMatch(viewerJs, /saveTimer/, "no autosave debounce timer should remain"); }); await test("excalidraw-viewer.js allows fullscreen in the iframe", () => { assert.match( viewerJs, /setAttribute\(\s*['"]allow['"]\s*,\s*['"]fullscreen['"]\s*\)/, "iframe must set allow=fullscreen" ); assert.match( viewerJs, /setAttribute\(\s*['"]allowfullscreen['"]\s*,\s*['"]['"]\s*\)/, "iframe must set the allowfullscreen attribute" ); }); await test("excalidraw-editor.html exposes a fullscreen button", () => { assert.match(editorHtml, /id="btn-fullscreen"/, "fullscreen button missing"); assert.match( editorHtml, /documentElement\.requestFullscreen/, "the button must request fullscreen on the editor document" ); }); await test("excalidraw-editor.html — toolbar flush right at 45%", () => { const rule = editorHtml.match(/#excalidraw-toolbar\s*\{([^}]*)\}/); assert.ok(rule, "#excalidraw-toolbar rule not found"); assert.match(rule[1], /right:\s*0\b/, "toolbar must be flush against the right edge"); assert.match(rule[1], /top:\s*45%/, "group must start at 45% of the height"); assert.match(rule[1], /flex-direction:\s*column/, "buttons must stay stacked vertically"); }); console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`); process.exit(passCount === testCount ? 0 : 1);