234 lines
9.2 KiB
JavaScript
234 lines
9.2 KiB
JavaScript
#!/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(
|
|
`<!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 + routed 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("/excalidraw-editor.html"), `src: ${iframe.src}`);
|
|
assert.ok(!iframe.src.includes("/static/excalidraw-editor.html"), `route avec nonce CSP: ${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); |