#!/usr/bin/env node /** * ObsiGate — Inline edition tests (ROADMAP #93). * * « Editer » and « Forge » must replace the document read view instead of * opening an overlay on top of it (`#editor-container` is moved into the * content area, so the AI assistant sidebar stays usable while editing). * * Covers: * - frontend/js/editor-inline.js — DOM behaviour (mount/unmount/classes) * - the wiring of that module in viewer.js / utils.js / sync.js / bookslm.js * (static checks, like tests/frontend/toolbar-order.test.mjs) * - the Forge iframe reload hook (frontend/editor-poc.html) * - the CSS that makes the editor fill the content area (frontend/style.css) * * Usage: node tests/frontend/editor-inline.test.mjs */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; 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, "..", ".."); const dom = new JSDOM( `
Accueil

Lecture

ObsiGate
`, { url: "https://example.com/", 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; const mod = await import( pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "editor-inline.js")).href ); const { INLINE_CONTAINER_CLASS, INLINE_HOST_CLASS, INLINE_MODAL_CLASS, getEditorModal, getEditorContainer, getInlineHostArea, isInlineEditorActive, mountEditorInline, unmountEditorInline, } = mod; let testCount = 0; let failCount = 0; function test(name, fn) { testCount++; try { fn(); console.log(` ✓ ${name}`); } catch (err) { failCount++; console.error(` ✗ ${name}\n ${err.message}`); process.exitCode = 1; } } const modal = () => document.getElementById("editor-modal"); const container = () => document.getElementById("editor-container"); const area = () => document.getElementById("content-area"); function reset() { const c = container(); const m = modal(); if (c.parentElement !== m) m.appendChild(c); c.classList.remove(INLINE_CONTAINER_CLASS); m.classList.remove(INLINE_MODAL_CLASS); area().classList.remove(INLINE_HOST_CLASS); area().innerHTML = '

Lecture

'; delete w._activePaneContentArea; delete w.PaneManager; delete w.getActiveContentArea; } console.log("Inline edition (#93) — editor-inline.js"); test("lookup helpers resolve the container and the host area", () => { reset(); assert.equal(getEditorModal(), modal()); assert.equal(getEditorContainer(), container()); assert.equal(getInlineHostArea(), area()); assert.equal(isInlineEditorActive(), false); }); test("mount moves the container into the content area and drops the read view", () => { reset(); assert.equal(mountEditorInline(area()), true); assert.equal(container().parentElement, area()); assert.equal(area().querySelector(".md-content"), null, "read view replaced"); assert.ok(container().classList.contains(INLINE_CONTAINER_CLASS)); assert.ok(area().classList.contains(INLINE_HOST_CLASS)); assert.ok(modal().classList.contains(INLINE_MODAL_CLASS)); assert.equal(isInlineEditorActive(), true); }); test("mount keeps the overlay mounted so the mobile ribbon keeps working", () => { reset(); mountEditorInline(area()); assert.ok(modal(), "overlay still in the document"); assert.equal(container().closest("#editor-modal"), null, "container left the overlay"); }); test("mount is idempotent on the same host area", () => { reset(); mountEditorInline(area()); assert.equal(mountEditorInline(area()), true); assert.equal(area().querySelectorAll(".editor-container").length, 1); }); test("mount refuses a missing area", () => { reset(); assert.equal(mountEditorInline(null), false); assert.equal(isInlineEditorActive(), false); }); test("unmount returns the host area, restores the overlay and clears classes", () => { reset(); mountEditorInline(area()); const host = unmountEditorInline(); assert.equal(host, area()); assert.equal(container().parentElement, modal()); assert.equal(container().classList.contains(INLINE_CONTAINER_CLASS), false); assert.equal(area().classList.contains(INLINE_HOST_CLASS), false); assert.equal(modal().classList.contains(INLINE_MODAL_CLASS), false); assert.equal(isInlineEditorActive(), false); assert.equal(area().children.length, 0, "host area left empty for the read view re-render"); }); test("unmount without an inline session is a no-op", () => { reset(); assert.equal(unmountEditorInline(), null); assert.equal(container().parentElement, modal()); }); test("host area follows the active pane in split view", () => { reset(); const paneArea = document.createElement("div"); paneArea.className = "content-area pane-content"; document.body.appendChild(paneArea); w.PaneManager = { isSplit: () => true }; w.getActiveContentArea = () => paneArea; assert.equal(getInlineHostArea(), paneArea); // A transient render override (pane-manager _renderFileInPane) wins. w._activePaneContentArea = area(); assert.equal(getInlineHostArea(), area()); assert.equal(mountEditorInline(paneArea), true); assert.equal(container().parentElement, paneArea); unmountEditorInline(); paneArea.remove(); reset(); }); // ── Static wiring checks ─────────────────────────────────────────────────── const read = (...parts) => readFileSync(path.join(REPO_ROOT, ...parts), "utf8"); const viewerSrc = read("frontend", "js", "viewer.js"); const utilsSrc = read("frontend", "js", "utils.js"); const syncSrc = read("frontend", "js", "sync.js"); const bookslmSrc = read("frontend", "js", "bookslm.js"); const indexSrc = read("frontend", "index.html"); const cssSrc = read("frontend", "style.css"); const forgeSrc = read("frontend", "editor-poc.html"); console.log("Inline edition (#93) — wiring"); test("index.html gives the editor container a stable id", () => { assert.match(indexSrc, /
/); }); test("viewer.js mounts the editor inline when rendering is taken over", () => { assert.match(viewerSrc, /import \{ isInlineEditorActive \} from '\.\/editor-inline\.js';/); const guard = viewerSrc.match(/export function renderFile\(data\) \{([\s\S]*?)\n const area = getContentArea\(\);/); assert.ok(guard, "renderFile head not found"); assert.match(guard[1], /if \(isInlineEditorActive\(\)\) detachInlineEditor\(\);/); }); test("viewer.js Forge button mounts inline and sizes the iframe accordingly", () => { assert.match(viewerSrc, /var inline = activateInlineEditor\(data\.vault, data\.path\);/); assert.match( viewerSrc, /iframe\.style\.cssText = "width:100%;height:" \+ \(inline \? "100%" : "82vh"\) \+ ";border:none;display:block;";/, ); }); test("utils.js activateInlineEditor only takes over the displayed document", () => { const fn = utilsSrc.match(/export function activateInlineEditor\(([\s\S]*?)\n\}/); assert.ok(fn, "activateInlineEditor not found"); assert.match(fn[1], /state\.currentVault !== vaultName \|\| state\.currentPath !== filePath/); assert.match(fn[1], /mountEditorInline\(area\)/); }); test("utils.js openEditor mounts inline, closeEditor releases the host area", () => { assert.match(utilsSrc, /activateInlineEditor\(vaultName, filePath\);/); const close = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/); assert.ok(close, "closeEditor not found"); assert.match(close[1], /const host = unmountEditorInline\(\);/); assert.match(close[1], /if \(host\) \{/); }); test("utils.js detachInlineEditor tears the session down without re-rendering", () => { const fn = utilsSrc.match(/export function detachInlineEditor\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, "detachInlineEditor not found"); assert.match(fn[1], /if \(!isInlineEditorActive\(\)\) return;/); assert.match(fn[1], /state\.editorView\.destroy\(\)/); assert.match(fn[1], /unmountEditorInline\(\)/); }); test("utils.js reloads the displayed document after an AI write", () => { const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path\) \{([\s\S]*?)\n\}/); assert.ok(fn, "reloadExternalWrite not found"); assert.match(fn[1], /postMessage\(\{ type: 'parent-reload' \}, '\*'\)/); assert.match(fn[1], /suppressAutoSaveOnce = true;/); assert.match(utilsSrc, /window\.addEventListener\("obsigate:file-written"/); assert.match(utilsSrc, /if \(suppressAutoSaveOnce\) \{/, "autosave skipped on programmatic reload"); }); test("sync.js forge-close goes through the shared close path", () => { assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/); const handler = syncSrc.match(/if \(e\.data\.type === 'forge-close'\) \{([\s\S]*?)\n \}/); assert.ok(handler, "forge-close handler not found"); assert.match(handler[1], /closeEditor\(\);/); }); test("bookslm.js reports the edited document and the assistant writes", () => { assert.match(bookslmSrc, /const editing = this\._editingDocument\(\);/); assert.match(bookslmSrc, /if \(editing\) ctx\.editing = editing;/); assert.match(bookslmSrc, /this\._notifyFileWritten\(data\);/); const notify = bookslmSrc.match(/_notifyFileWritten\(data\) \{([\s\S]*?)\n \}/); assert.ok(notify, "_notifyFileWritten not found"); for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup"]) { assert.ok(notify[1].includes(`'${tool}'`), `${tool} missing from the write tools`); } assert.match(notify[1], /new CustomEvent\('obsigate:file-written'/); }); test("editor-poc.html reloads Forge buffer on parent-reload", () => { assert.match(forgeSrc, /if \(e\.data\.type === 'parent-reload'\) \{/); const handler = forgeSrc.match(/if \(e\.data\.type === 'parent-reload'\) \{([\s\S]*?)\n \}/); assert.ok(handler, "parent-reload handler not found"); assert.match(handler[1], /clearTimeout\(saveTimer\);/); assert.match(handler[1], /loadFile\(\);/); }); test("style.css lets the inline editor fill the content area", () => { assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{/); assert.match(cssSrc, /\.editor-modal\.editor-inline-mode \{[\s\S]*?pointer-events: none;/); assert.match(cssSrc, /\.content-area\.editor-inline-host,[\s\S]*?display: flex;/); const fill = cssSrc.match(/\.content-area\.editor-inline-host \.editor-container\.editor-inline,[\s\S]*?\{([\s\S]*?)\n\}/); assert.ok(fill, "inline container rule not found"); assert.match(fill[1], /flex: 1;/); assert.match(fill[1], /height: auto;/, "must win over the mobile 100vh rule"); assert.match(fill[1], /max-width: none;/); }); // ── BUG-045: one scroll surface only in the CodeMirror editor ── test("style.css: #editor-body scrolls through the CodeMirror scroller only", () => { const body = cssSrc.match(/\.editor-body\.editor-body-cm \{([^}]*)\}/); assert.ok(body, "combined .editor-body.editor-body-cm rule not found"); assert.match(body[1], /overflow:\s*hidden;/, "editor body must not scroll itself"); assert.match(body[1], /display:\s*flex;/); assert.match(body[1], /flex-direction:\s*column;/); const cm = cssSrc.match(/\.editor-body\.editor-body-cm \.cm-editor \{([^}]*)\}/); assert.ok(cm, ".cm-editor fill rule not found"); assert.match(cm[1], /flex:\s*1 1 auto;/); assert.match(cm[1], /height:\s*auto;/, "100% height below the AI toolbar row caused the double scrollbar"); // The global legacy override must be gone: scroller auto-height + forced overflow-y. assert.ok(!/\.cm-scroller \{[^}]*min-height:\s*100%;/.test(cssSrc), "global .cm-scroller min-height override reintroduces the double scrollbar"); }); console.log(`\n${testCount - failCount}/${testCount} tests passed`); if (failCount > 0) { console.error(`${failCount} test(s) failed`); process.exit(1); }