Files
ObsiGate/tests/frontend/editor-inline.test.mjs
T
bruno e3df4bbf00
CI / lint (push) Successful in 1m26s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m17s
CI / build (push) Successful in 56s
CI / e2e (push) Successful in 10m47s
feat(editeur): #93 edition inline — Editer/Forge remplacent la vue lecture
Le conteneur d'edition (#editor-container, CodeMirror ou iframe Forge) est deplace dans la zone de contenu du document (#content-area ou pane active) au lieu de l'overlay plein ecran : le mode edition remplace la vue lecture. L'overlay reste monte (transparent, pointer-events: none) car le ruban d'edition mobile y est ancre ; il ne sert plus que de repli quand la cible n'est pas le document affiche. Garde-fou dans renderFile() pour liberer proprement la session (destroy CodeMirror/Yjs, retrait de l'iframe Forge) ; forge-close passe par closeEditor() ; cache d'onglet invalide au retour en lecture.

Assistant IA : app_context.editing annonce le document en cours d'edition (frontend bookslm.js + backend bookslm.py), et chaque ecriture d'outil (edit_file, append_to_file, create_file, restore_backup) recharge le document affiche (obsigate:file-written) — tampon CodeMirror remplace avec auto-save neutralisee, parent-reload pour l'iframe Forge, re-rendu de la vue lecture sinon.

Tests : tests/frontend/editor-inline.test.mjs (19), tests/test_bookslm.py::TestGeneralPrompt (3 nouveaux). Fiche : docs/features/editeur-inline.md.
2026-09-15 23:40:10 -04:00

290 lines
12 KiB
JavaScript

#!/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(
`<!DOCTYPE html><html><body>
<div class="app-container">
<div class="content-wrapper">
<div class="content-area" id="content-area">
<div class="file-header"><div class="file-title">Accueil</div></div>
<div class="md-content" id="file-rendered-content"><p>Lecture</p></div>
</div>
</div>
</div>
<div class="editor-modal" id="editor-modal">
<div class="editor-container" id="editor-container">
<div class="editor-header"><span class="editor-brand">ObsiGate</span></div>
<div class="editor-body editor-body-cm" id="editor-body"></div>
<div class="editor-footer"></div>
</div>
</div>
</body></html>`,
{ 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 = '<div class="md-content" id="file-rendered-content"><p>Lecture</p></div>';
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, /<div class="editor-container" id="editor-container">/);
});
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;/);
});
console.log(`\n${testCount - failCount}/${testCount} tests passed`);
if (failCount > 0) {
console.error(`${failCount} test(s) failed`);
process.exit(1);
}