#!/usr/bin/env node /** * ObsiGate — JSDOM tests for the native mobile editor UX (ROADMAP #69). * * Covers the pure helpers of frontend/js/mobile-editor.js (Markdown formatting, * swipe detection, zoom clamping) and the DOM wiring (floating toolbar, reading * mode toggling, bottom sheet construction). The swipe/navigation network paths * are exercised manually / in E2E. * * Usage: node tests/frontend/mobile-editor.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 { state } from "../../frontend/js/state.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, "..", ".."); // ── JSDOM bootstrap ──────────────────────────────────────────────────────── const dom = new JSDOM( `
`, { url: "https://example.com/", pretendToBeVisual: true }, ); const w = dom.window; globalThis.window = w; globalThis.document = w.document; Object.defineProperty(globalThis, "navigator", { value: w.navigator, configurable: true, writable: true, }); globalThis.localStorage = w.localStorage; globalThis.MutationObserver = w.MutationObserver; const mod = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "mobile-editor.js")).href ); const { FORMAT_ACTIONS, MIN_EDITOR_FONT, MAX_EDITOR_FONT, clampZoom, detectSwipe, formatChange, insertText, applyFormatToTextarea, applyEditorFontSize, initMobileEditor, toggleReadingMode, isReadingMode, updateReadingButtonVisibility, } = mod; let testCount = 0; let passCount = 0; function test(name, fn) { testCount++; try { fn(); console.log(` ✓ ${name}`); passCount++; } catch (e) { console.log(` ✗ ${name}`); console.log(` ${e.message}`); } } console.log("\n── mobile-editor.js unit tests (#69) ──\n"); // ── Pure helpers ─────────────────────────────────────────────────────────── test("FORMAT_ACTIONS exposes the five expected actions", () => { assert.deepEqual(FORMAT_ACTIONS, ["bold", "italic", "code", "list", "link"]); }); test("clampZoom clamps to [min, max] and falls back to 13.5", () => { assert.equal(clampZoom(5), MIN_EDITOR_FONT); assert.equal(clampZoom(100), MAX_EDITOR_FONT); assert.equal(clampZoom(16), 16); assert.equal(clampZoom("abc"), 13.5); assert.equal(clampZoom(NaN), 13.5); }); test("detectSwipe returns null below the threshold", () => { assert.equal(detectSwipe(20, 0), null); assert.equal(detectSwipe(-20, 0), null); }); test("detectSwipe ignores vertical-dominant gestures", () => { assert.equal(detectSwipe(10, 120), null); }); test("detectSwipe detects left and right horizontal swipes", () => { assert.equal(detectSwipe(-90, 5), "left"); assert.equal(detectSwipe(90, -5), "right"); }); test("formatChange bold wraps the selection", () => { const c = formatChange("hello world", 0, 5, "bold"); assert.equal(c.changes.insert, "**hello**"); assert.equal(c.changes.from, 0); assert.equal(c.changes.to, 5); assert.deepEqual(c.selection, { anchor: 2, head: 7 }); }); test("formatChange bold with no selection inserts markers and selects inside", () => { const c = formatChange("abc", 1, 1, "bold"); assert.equal(c.changes.insert, "****"); assert.deepEqual(c.selection, { anchor: 3, head: 3 }); }); test("formatChange bold toggles off when markers are selected", () => { const c = formatChange("**hi**", 0, 6, "bold"); assert.equal(c.changes.insert, "hi"); assert.deepEqual(c.selection, { anchor: 0, head: 2 }); }); test("formatChange italic and code use the right markers", () => { assert.equal(formatChange("x", 0, 1, "italic").changes.insert, "*x*"); assert.equal(formatChange("x", 0, 1, "code").changes.insert, "`x`"); }); test("formatChange list prefixes each selected line", () => { const c = formatChange("a\nb\nc", 0, 5, "list"); assert.equal(c.changes.insert, "- a\n- b\n- c"); assert.equal(c.changes.from, 0); assert.equal(c.changes.to, 5); }); test("formatChange list toggles an existing list off", () => { const c = formatChange("- a\n- b", 0, 7, "list"); assert.equal(c.changes.insert, "a\nb"); }); test("formatChange list only affects the lines spanned by the cursor", () => { const c = formatChange("head\nbody\ntail", 5, 5, "list"); assert.equal(c.changes.insert, "- body"); assert.equal(c.changes.from, 5); assert.equal(c.changes.to, 9); }); test("formatChange link builds [selection](url) and selects the url", () => { const c = formatChange("click", 0, 5, "link"); assert.equal(c.changes.insert, "[click](url)"); assert.deepEqual(c.selection, { anchor: 8, head: 11 }); }); test("formatChange returns null for an unknown action", () => { assert.equal(formatChange("abc", 0, 0, "strike"), null); }); test("insertText builds an insertion change at the cursor", () => { const c = insertText(2, 2, "pasted"); assert.deepEqual(c.changes, { from: 2, to: 2, insert: "pasted" }); assert.deepEqual(c.selection, { anchor: 8, head: 8 }); }); // ── Textarea application ─────────────────────────────────────────────────── test("applyFormatToTextarea formats the current textarea selection", () => { const ta = w.document.createElement("textarea"); ta.value = "hello world"; ta.selectionStart = 0; ta.selectionEnd = 5; assert.equal(applyFormatToTextarea(ta, "bold"), true); assert.equal(ta.value, "**hello** world"); assert.equal(ta.selectionStart, 2); assert.equal(ta.selectionEnd, 7); }); test("applyFormatToTextarea returns false without an element", () => { assert.equal(applyFormatToTextarea(null, "bold"), false); }); // ── DOM wiring ───────────────────────────────────────────────────────────── test("initMobileEditor builds the floating toolbar with the five actions", () => { initMobileEditor(); const bar = w.document.getElementById("me-toolbar"); assert.ok(bar, "toolbar exists"); for (const action of FORMAT_ACTIONS) { assert.ok(w.document.getElementById("me-fmt-" + action), action + " button exists"); } assert.ok(w.document.getElementById("me-paste"), "paste button exists"); assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists"); }); test("initMobileEditor is idempotent", () => { initMobileEditor(); const bars = w.document.querySelectorAll("#me-toolbar"); assert.equal(bars.length, 1); }); test("applyEditorFontSize persists and clamps the size", () => { const applied = applyEditorFontSize(999); assert.equal(applied, MAX_EDITOR_FONT); assert.equal(localStorage.getItem("obsigate-editor-font-size"), String(MAX_EDITOR_FONT)); }); test("toggleReadingMode toggles the body class and localStorage", () => { assert.equal(isReadingMode(), false); assert.equal(toggleReadingMode(true), true); assert.equal(isReadingMode(), true); assert.equal(localStorage.getItem("obsigate-reading-mode"), "1"); assert.equal(toggleReadingMode(false), false); assert.equal(isReadingMode(), false); assert.equal(localStorage.getItem("obsigate-reading-mode"), "0"); }); test("reading mode exposes enter and exit controls", () => { assert.ok(w.document.getElementById("me-reading-btn"), "enter button exists"); assert.ok(w.document.getElementById("me-reading-exit"), "exit button exists"); }); test("reading button only shows while a file is open", () => { const btn = w.document.getElementById("me-reading-btn"); state.currentPath = null; updateReadingButtonVisibility(); assert.ok(btn.hidden, "hidden without an open file"); state.currentPath = "Notes/note.md"; updateReadingButtonVisibility(); assert.ok(!btn.hidden, "shown when a file is open"); state.currentPath = null; updateReadingButtonVisibility(); assert.ok(btn.hidden, "hidden again once the file is closed"); }); test("reading button is hidden while the assistant panel is open", () => { const btn = w.document.getElementById("me-reading-btn"); const panel = w.document.createElement("div"); panel.className = "bookslm-panel open"; w.document.body.appendChild(panel); state.currentPath = "Notes/note.md"; updateReadingButtonVisibility(); assert.ok(btn.hidden, "hidden while the assistant is expanded"); panel.classList.add("hidden"); updateReadingButtonVisibility(); assert.ok(!btn.hidden, "shown again once the assistant is collapsed"); panel.remove(); state.currentPath = null; updateReadingButtonVisibility(); }); console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1);