fix: Editeur Excalidraw - diagramme vide (CSS + appState) et auto-save pendant l'edition (BUG-064, BUG-065)
This commit is contained in:
@@ -68,6 +68,41 @@ test.describe('Excalidraw — .excalidraw file support', () => {
|
||||
await expect(canvas).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('opens an Excalidraw-app export (collaborators as JSON object) — canvas renders', async ({ page }) => {
|
||||
// Regression: files exported by the Excalidraw app / Obsidian plugin store
|
||||
// `appState.collaborators` as a plain `{}` (a Map serialized to JSON).
|
||||
// Passing it back to `initialData` made Excalidraw 0.18 crash with
|
||||
// "e.appState.collaborators.forEach is not a function" and left the canvas
|
||||
// blank (0 <canvas> in the iframe).
|
||||
await openFile(page, 'TestVault', 'diagram-app-export.excalidraw');
|
||||
await waitForExcalidrawIframe(page);
|
||||
|
||||
const frame = page.locator('.content-area iframe').contentFrame();
|
||||
const canvas = frame.locator('canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('loads Excalidraw stylesheet — canvas is sized to the viewport (not the 2^25 cap)', async ({ page }) => {
|
||||
// Regression: without `@excalidraw/excalidraw`'s stylesheet, `.excalidraw`
|
||||
// has no fixed height; Excalidraw's ResizeObserver feedback loop then grows
|
||||
// the canvas to its 2^25 hard cap (33554432px), which the browser cannot
|
||||
// draw → the scene stays blank even though the data was loaded.
|
||||
await openFile(page, 'TestVault', 'diagram.excalidraw');
|
||||
await waitForExcalidrawIframe(page);
|
||||
|
||||
const frame = page.locator('.content-area iframe').contentFrame();
|
||||
const canvas = frame.locator('canvas').first();
|
||||
await expect(canvas).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const dims = await canvas.evaluate((el) => ({ w: el.width, h: el.height }));
|
||||
expect(dims.h).toBeGreaterThan(100);
|
||||
expect(dims.h).toBeLessThan(32768);
|
||||
|
||||
const wrapH = await frame.locator('.excalidraw').first()
|
||||
.evaluate((el) => parseFloat(getComputedStyle(el).height));
|
||||
expect(wrapH).toBeLessThan(32768);
|
||||
});
|
||||
|
||||
test('opens .excalidraw.md (Obsidian plugin format) — decompresses and renders', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'diagram.excalidraw.md');
|
||||
const iframe = await waitForExcalidrawIframe(page);
|
||||
|
||||
@@ -12,6 +12,7 @@ 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);
|
||||
@@ -138,5 +139,95 @@ 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);
|
||||
@@ -317,3 +317,25 @@ class TestTokenInvalidation:
|
||||
"/api/auth/me", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert me.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# BUG-064 — Excalidraw editor stylesheet (esm.sh) must pass CSP
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCspExcalidrawStylesheet:
|
||||
def test_style_src_allows_esm_sh(self, client):
|
||||
"""The Excalidraw editor loads its stylesheet from esm.sh.
|
||||
|
||||
Without this directive the CSS is blocked, the editor is unstyled and
|
||||
its container enters a resize feedback loop up to the 2^25 canvas cap,
|
||||
leaving the scene blank (BUG-064).
|
||||
"""
|
||||
resp = client.get("/api/health")
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
style_src = next(
|
||||
(d.strip() for d in csp.split(";") if d.strip().startswith("style-src")),
|
||||
"",
|
||||
)
|
||||
assert style_src, "CSP style-src directive not found"
|
||||
assert "https://esm.sh" in style_src
|
||||
|
||||
Reference in New Issue
Block a user