fix(ai+editeur): BUG-046 erreur lisible et vault réel pour l'ajout IA au document; BUG-045 fin de la double scrollbar
BUG-046: 'Ajouter du texte au document courant' échouait avec 'Échec de l'action: [object Object]'. Trois causes corrigées: - continuation de confirmation sans payload -> second Appliquer POSTait sans 'message' (422); le payload d'origine est désormais transmis - detail 422 FastAPI (tableau d'objets) stringifié tel quel -> nouvelle _responseError() qui aplatit en message lisible - prompt documents/dossier muet sur le vault -> le modèle inventait 'vault: test' et append_to_file échouait en silence; build_system_prompt nomme le vault et enjoint les outils d'écriture de l'utiliser exactement BUG-045: double barre de défilement dans l'éditeur Editer (fichiers longs): #editor-body overflow:auto + cm-editor height:100% sous la barre IA -> corps et scroller CodeMirror défilant tous les deux. Flex column, seul le scroller défile; override global .cm-scroller retiré. SW v20. Vérifié: pytest 1038, ruff backend 0, tests frontend AI 84/84, editor-inline 25/25, validate-imports 38 modules; Playwright sur l'instance de test (un seul conteneur scrollable) et flux agent complet append_to_file ok=true sur fichier réel
This commit is contained in:
@@ -191,6 +191,34 @@ async function main() {
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
// ── 4a-bis. BUG-046: readable errors + payload carry-over on confirmation resume ──
|
||||
await test("_responseError flattens FastAPI 422 array details (no [object Object])", async () => {
|
||||
const b = new BooksLM();
|
||||
const resp422 = {
|
||||
status: 422,
|
||||
json: async () => ({ detail: [{ type: "missing", loc: ["body", "message"], msg: "Field required", input: {} }] }),
|
||||
};
|
||||
const err422 = await b._responseError(resp422);
|
||||
assert.equal(err422.message, "Field required", `got "${err422.message}"`);
|
||||
assert.ok(!err422.message.includes("[object Object]"), "must never stringify to [object Object]");
|
||||
|
||||
const respStr = { status: 400, json: async () => ({ detail: "vault invalide" }) };
|
||||
assert.equal((await b._responseError(respStr)).message, "vault invalide");
|
||||
|
||||
const respEmpty = { status: 500, json: async () => { throw new Error("no body"); } };
|
||||
assert.equal((await b._responseError(respEmpty)).message, "HTTP 500");
|
||||
});
|
||||
|
||||
await test("confirmation resume keeps the payload and formats errors (BUG-046)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
|
||||
const apply = src.slice(src.indexOf("async _applyConfirmation"));
|
||||
assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/,
|
||||
"continuation must inherit the original request payload (no null payload)");
|
||||
assert.match(apply, /await this\._responseError\(resp\)/,
|
||||
"resume path must format non-OK responses via _responseError");
|
||||
});
|
||||
|
||||
// ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ──
|
||||
await test("thread renders chronologically (user post above its answer)", () => {
|
||||
const b = new BooksLM();
|
||||
|
||||
@@ -67,6 +67,7 @@ const {
|
||||
isInlineEditorActive,
|
||||
mountEditorInline,
|
||||
unmountEditorInline,
|
||||
queryEditor,
|
||||
} = mod;
|
||||
|
||||
let testCount = 0;
|
||||
@@ -161,6 +162,17 @@ test("unmount without an inline session is a no-op", () => {
|
||||
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");
|
||||
@@ -193,12 +205,37 @@ 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, /<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';/);
|
||||
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\(\);/);
|
||||
@@ -206,6 +243,8 @@ test("viewer.js mounts the editor inline when rendering is taken over", () => {
|
||||
|
||||
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;";/,
|
||||
@@ -245,12 +284,32 @@ test("utils.js reloads the displayed document after an AI write", () => {
|
||||
});
|
||||
|
||||
test("sync.js forge-close goes through the shared close path", () => {
|
||||
assert.match(syncSrc, /import \{ closeEditor \} from '\.\/utils\.js';/);
|
||||
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 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;/);
|
||||
@@ -282,6 +341,22 @@ test("style.css lets the inline editor fill the content area", () => {
|
||||
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`);
|
||||
|
||||
Reference in New Issue
Block a user