#!/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, queryEditor, } = 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("queryEditor follows the container (modal → content area)", () => { reset(); assert.equal(queryEditor(".editor-brand").textContent, "ObsiGate"); mountEditorInline(area()); assert.ok(queryEditor(".editor-brand"), "brand reachable while mounted inline"); assert.equal(document.getElementById("editor-modal").querySelector(".editor-brand"), null, "brand really left the overlay"); unmountEditorInline(); assert.ok(document.getElementById("editor-modal").querySelector(".editor-brand"), "brand back in the overlay"); reset(); }); 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("utils.js / viewer.js import every editor-inline helper they call", () => { const helpers = ["getEditorContainer", "getInlineHostArea", "isInlineEditorActive", "mountEditorInline", "unmountEditorInline", "queryEditor"]; const strip = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); for (const [name, raw] of [["utils.js", utilsSrc], ["viewer.js", viewerSrc]]) { const src = strip(raw); const marker = "from './editor-inline.js';"; const end = raw.indexOf(marker); const start = end === -1 ? -1 : raw.lastIndexOf("import {", end); const imported = end === -1 || start === -1 ? [] : raw .slice(start + "import {".length, end) .replace(/\}/g, "") .split(",") .map((s) => s.trim()) .filter(Boolean); for (const helper of helpers) { if (new RegExp(`\\b${helper}\\(`).test(src)) { assert.ok(imported.includes(helper), `${name} calls ${helper}() without importing it`); } } } }); 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.ok(viewerSrc.includes("import { isInlineEditorActive, queryEditor } 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.ok(viewerSrc.includes('var brand = queryEditor(".editor-brand");'), "header query must follow the container"); assert.equal(viewerSrc.includes('modal.querySelector(".editor-brand")'), false, "modal-scoped brand query would break inline mode"); 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, force = false\) \{([\s\S]*?)\n\}/); assert.ok(fn, "reloadExternalWrite not found"); assert.match(fn[1], /postMessage\(\{ type: 'parent-reload', force: !!force \}, '\*'\)/); 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("utils.js does not clobber unsaved edits on a non-forced reload (BUG-055)", () => { const fn = utilsSrc.match(/async function reloadExternalWrite\(vault, path, force = false\) \{([\s\S]*?)\n\}/); assert.ok(fn, "reloadExternalWrite not found"); // Guard before the CodeMirror dispatch assert.match(fn[1], /if \(!force && dirtyDot && dirtyDot\.classList\.contains\("dirty"\)\) return;/); // The AI write forces the reload past unsaved local changes assert.match(utilsSrc, /reloadExternalWrite\(detail\.vault, detail\.path, true\);/); }); test("sync.js forge-close goes through the shared close path", () => { assert.ok(syncSrc.includes("import { closeEditor, reloadExternalWrite } from './utils.js';"), "utils import missing"); 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("sync.js leaves fullscreen before opening the Forge assistant (BUG-056)", () => { const handler = syncSrc.match(/if \(e\.data\.type === 'forge-open-ai'\) \{([\s\S]*?)\n \}/); assert.ok(handler, "forge-open-ai handler not found"); assert.match(handler[1], /document\.fullscreenElement/); assert.match(handler[1], /document\.exitFullscreen\(\)/); assert.match(handler[1], /openForCurrentContext\(\)/); }); test("sync.js keeps an open edition session alive on external file changes", () => { const sse = syncSrc.match(/const changed = \(data\.changes \|\| \[\]\)([\s\S]*?)\n \}/); assert.ok(sse, "SSE index_updated refresh block not found"); assert.ok(sse[1].includes("reloadExternalWrite(state.currentVault, state.currentPath);"), "SSE refresh must not re-render the read view"); assert.ok(!/^\s*openFile\(state\.currentVault/m.test(sse[1]), "openFile must not be called directly here"); }); test("tab activation releases an inline edition session first", () => { const uiActivate = read("frontend", "js", "ui.js").match(/async activate\(tabId\) \{([\s\S]*?)\n \},/); assert.ok(uiActivate, "TabManager.activate not found"); assert.match(uiActivate[1], /detachInlineEditor\(\);/); }); test("pane activation releases an inline edition session first", () => { const paneSrc = read("frontend", "js", "pane-manager.js"); const paneActivate = paneSrc.match(/async activate\(tabId\) \{([\s\S]*?)\n const cache/); assert.ok(paneActivate, "PaneTabManager.activate not found"); assert.match(paneActivate[1], /detachInlineEditor\(\);/); }); 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("editor-poc.html inserts assistant text on parent-insert (BUG-057)", () => { assert.match(forgeSrc, /if \(e\.data\.type === 'parent-insert' && typeof e\.data\.text === 'string'\) \{/); const handler = forgeSrc.match(/if \(e\.data\.type === 'parent-insert' && typeof e\.data\.text === 'string'\) \{([\s\S]*?)\n \}/); assert.ok(handler, "parent-insert handler not found"); assert.match(handler[1], /insertAtCursor\(/); }); 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"); }); // ── BUG-054: the shared save button must not stay stuck on the spinner ── test("utils.js resetSaveButton restores the checkmark and re-enables the button", () => { const fn = utilsSrc.match(/function resetSaveButton\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, "resetSaveButton not found"); assert.match(fn[1], /saveBtn\.disabled = false;/); assert.match(fn[1], /saveBtn\.innerHTML = '✓'/); assert.match(fn[1], /saveBtn\.style\.background = '';/); }); test("utils.js openEditor resets the save button before each session", () => { const fn = utilsSrc.match(/async function openEditor\(vaultName, filePath\) \{([\s\S]*?)\n\}/); assert.ok(fn, "openEditor not found"); assert.match(fn[1], /resetSaveButton\(\);/); }); test("utils.js closeEditor resets the save button on success/cancel/delete", () => { const fn = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, "closeEditor not found"); assert.match(fn[1], /resetSaveButton\(\);/); }); test("utils.js saveFile restores the save button when the request fails", () => { assert.match(utilsSrc, /console\.error\("Save error:", err\);\s*\n\s*resetSaveButton\(\);/); }); // ── #101: Forge uses the shared AI assistant + native fullscreen ── test("editor-poc.html opens the shared AI assistant from its AI button", () => { assert.ok(!/id="ai-panel"/.test(forgeSrc), "Forge mini AI panel markup must be gone"); assert.match(forgeSrc, /postMessage\(\{ type: 'forge-open-ai' \}, '\*'\)/); assert.match(forgeSrc, /btnAI\.addEventListener\('click', openAssistant\)/); }); test("sync.js routes forge-open-ai to the BooksLM assistant", () => { const handler = syncSrc.match(/if \(e\.data\.type === 'forge-open-ai'\) \{([\s\S]*?)\n \}/); assert.ok(handler, "forge-open-ai handler not found"); assert.match(handler[1], /import\('\.\/bookslm\.js'\)/); assert.match(handler[1], /openForCurrentContext\(\)/); }); test("editor-poc.html AI calls use the assistant's configured provider/model", () => { const pick = forgeSrc.match(/function aiPickerSelection\(\) \{([\s\S]*?)\n \}/); assert.ok(pick, "aiPickerSelection not found"); assert.match(pick[1], /obsigate_ai_picker/); assert.match(forgeSrc, /var pick = aiPickerSelection\(\);/); assert.match(forgeSrc, /Object\.keys\(pick\)\.forEach/); }); test("editor-poc.html fixes AI endpoint names and translate param", () => { assert.match(forgeSrc, /'ai-longer':\s*\{ ep: 'make-longer'/); assert.match(forgeSrc, /'ai-shorter':\s*\{ ep: 'make-shorter'/); assert.match(forgeSrc, /'ai-translate':\s*\{ ep: 'translate', extra: \{ target_lang: 'en' \}/); }); test("editor-poc.html ghost completion follows the configured provider", () => { assert.match(forgeSrc, /ghostBody\.provider = ghostPick\.provider \|\| 'ollama';/); assert.match(forgeSrc, /ghostBody\.model = ghostPick\.model;/); }); test("editor-poc.html has a native fullscreen button", () => { assert.match(forgeSrc, /id="btn-fullscreen"/); assert.match(forgeSrc, /document\.documentElement\.requestFullscreen/); assert.match(forgeSrc, /document\.addEventListener\('fullscreenchange'/); }); test("viewer.js allows fullscreen in the Forge iframe", () => { assert.match(viewerSrc, /iframe\.setAttribute\("allow", "fullscreen"\)/); }); test("index.html has an editor fullscreen button with i18n title", () => { assert.match(indexSrc, /id="editor-fullscreen"/); assert.match(indexSrc, /data-i18n-attr="title:editor\.fullscreen"/); }); test("utils.js wires the editor fullscreen button and exits on close", () => { assert.match(utilsSrc, /function toggleEditorFullscreen\(\)/); assert.match(utilsSrc, /function updateFullscreenButton\(\)/); assert.match(utilsSrc, /document\.addEventListener\("fullscreenchange", updateFullscreenButton\)/); const close = utilsSrc.match(/function closeEditor\(\) \{([\s\S]*?)\n\}/); assert.ok(close, "closeEditor not found"); assert.match(close[1], /document\.exitFullscreen\(\)/); }); test("style.css makes the editor fill the screen in fullscreen", () => { assert.match(cssSrc, /\.editor-container:fullscreen \{/); assert.match(cssSrc, /\.editor-container:fullscreen \{[\s\S]*?height: 100vh;/); }); test("locales expose the fullscreen labels (FR + EN)", () => { const fr = JSON.parse(read("frontend", "locales", "fr.json")); const en = JSON.parse(read("frontend", "locales", "en.json")); for (const key of ["editor.fullscreen", "editor.exit_fullscreen"]) { assert.ok(fr[key], `fr.json missing ${key}`); assert.ok(en[key], `en.json missing ${key}`); } }); console.log(`\n${testCount - failCount}/${testCount} tests passed`); if (failCount > 0) { console.error(`${failCount} test(s) failed`); process.exit(1); }